Skip to main content
doc0blade47/fumadocsdocumentation home
Sign in

Obsidian Vault Integration

Last updated Oct 12, 2026100% grounded
Relevant source files

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

Overview

Obsidian Vault Integration empowers Fumadocs to ingest, parse, and transform raw Obsidian vaults directly into standards-compliant MDX content, assets, and structured metadata. By bridging proprietary Obsidian markdown features with modern documentation tooling, it resolves critical incompatibilities surrounding internal referencing and block formatting. The integration embodies design decisions focused on unified processor pipelines, virtual vault storage, and robust AST transformations that make interconnected knowledge bases fully compatible with Source Loader and Local Markdown Runtime.

Sources: packages/obsidian/src/convert.ts:57-118, packages/obsidian/src/mdx/index.ts:16-44, packages/obsidian/src/build-storage.ts:73-138

Public vault processing interface

Public API functions and configuration options

The public interface for the Obsidian integration centers around the fromVault function and writeVaultFiles, supported by structured configuration types. The Options interface extends ReadFilesOptions by incorporating optional convert properties via ConvertOptions and output parameters via WriteFilesOptions. Executing fromVault invokes readVaultFiles with the provided configuration, passes the resulting files to convertVaultFiles, and finally writes out the serialized artifacts using writeVaultFiles.

export interface Options extends ReadFilesOptions {
  convert?: ConvertOptions;
  out?: WriteFilesOptions;
}
 
export async function fromVault(options: Options) {
  const files = await readVaultFiles(options);
  const converted = await convertVaultFiles(files, options.convert);
 
  await writeVaultFiles(converted, options.out);
}

Sources: packages/obsidian/src/index.ts:10-20

Option PropertyTypeDefault ValueDescription
publicDirstring'public'Target directory for asset outputs mapped from media files.
contentDirstring'content/docs/vault'Target directory for parsed content and data files.
remarkPluginsPluggableList[]Extra remark plugins added to the parsing processor pipeline.
transformFrontmatterfunctionundefinedCallback hook to modify generated frontmatter data per file.

Sources: packages/obsidian/src/index.ts:28-28, packages/obsidian/src/convert.ts:34-49

Note

The convertVaultFiles processor integrates remarkParse, remarkGfm, remarkMath, user-supplied remarkPlugins, and internal storage plugins before initializing stringification via remarkMdx and remarkStringify. Sources: packages/obsidian/src/convert.ts:65-71

Output serialization and directory mapping

The file writing mechanism handles discrete output categories produced during vault conversion. The writeVaultFiles function accepts an array of OutputFile objects alongside WriteFilesOptions, mapping each file type to a designated base directory.

export async function writeVaultFiles(files: OutputFile[], options: WriteFilesOptions = {}) {
  const { publicDir = 'public', contentDir = 'content/docs/vault' } = options;
 
  const map = {
    asset: publicDir,
    content: contentDir,
    data: contentDir,
    custom: '',
  } as const;
 
  await Promise.all(
    files.map(async (file) => {
      const mappedPath = path.join(map[file.type], file.path);
 
      await fs.mkdir(path.dirname(mappedPath), { recursive: true });
      await fs.writeFile(mappedPath, file.content);
    }),
  );
}

Sources: packages/obsidian/src/index.ts:27-44

Warning

When file.type is set to 'custom', map['custom'] resolves to an empty string, causing the target file path to be written directly relative to the current working directory unless absolute path resolution is managed upstream. Sources: packages/obsidian/src/index.ts:34-39

Vault storage and path normalization

File discovery and path normalization

The storage engine processes raw vault inputs through path normalization and classification. The buildStorage function accepts an array of VaultFile objects and optional configuration, iterating through each file to normalize paths, resolve output destinations via rename presets or custom functions, and categorize files into distinct formats (content, data, or media).

export function buildStorage(
  rawFiles: VaultFile[],
  options: VaultStorageOptions = {},
): VaultStorage {
  const {
    url = (file) => {
      const segs = normalize(file)
        .split('/')
        .filter((v) => v.length > 0);
      return `/${segs.join('/')}`;
    },
    outputPath: outputPathOption = 'simple',
    enforceMdx = true,
  } = options;
  const getOutputPath =
    typeof outputPathOption === 'function'
      ? outputPathOption
      : createRenameOutput(outputPathOption);
 
  const storage = new Map<string, ParsedFile>();
 
  for (const rawFile of rawFiles) {
    const normalizedPath = normalize(rawFile.path);
    let outPath = getOutputPath(normalizedPath, rawFile);
    let parsed: ParsedFile;
 
    const ext = path.extname(normalizedPath);
 
    if (['.json', '.yaml', '.yml', '.toml'].includes(ext)) {
      parsed = {
        format: 'data',
        path: normalizedPath,
        _raw: rawFile._raw,
        outPath,
        content: rawFile.content,
      };
    } else if (['.md', '.mdx'].includes(ext)) {
      const { data, content } = frontmatter(String(rawFile.content));
      if (enforceMdx) {
        outPath = outPath.slice(0, -path.extname(outPath).length) + '.mdx';
      }
 
      parsed = {
        format: 'content',
        _raw: rawFile._raw,
        path: normalizedPath,
        outPath,
        frontmatter: frontmatterSchema.parse(data),
        content,
      };
    } else {
      parsed = {
        format: 'media',
        path: normalizedPath,
        _raw: rawFile._raw,
        outPath,
        content: rawFile.content,
        url: url(outPath, rawFile),
      };
    }
 
    storage.set(normalizedPath, parsed);
  }
 
  return { files: storage };
}

Sources: packages/obsidian/src/build-storage.ts:73-138

Path normalization is handled by the normalize helper, which enforces directory traversal security by throwing an error if a path points outside the vault. It normalizes directory separators using slash and strips leading ./ relative prefixes.

function normalize(filePath: string): string {
  filePath = slash(filePath);
  if (filePath.startsWith('../')) throw new Error(`${filePath} points outside of vault folder`);
 
  return filePath.startsWith('./') ? filePath.slice(2) : filePath;
}

Sources: packages/obsidian/src/build-storage.ts:160-165

Caution

Any file path beginning with ../ triggers an immediate error during path normalization, preventing unauthorized traversal outside the designated vault folder boundary. Sources: packages/obsidian/src/build-storage.ts:162-162

Call-chain execution walkthrough

The initialization sequence for processing vault files through remarkObsidian executes the following call chain:

  1. remarkObsidian — Receives files and initializes buildStorage with custom options.
  2. buildStorage — Iterates over raw files and invokes path handling.
  3. normalize — Sanitizes path separators and checks for directory traversal violations.

Sources: packages/obsidian/src/mdx/index.ts:16-22, packages/obsidian/src/build-storage.ts:73-95, packages/obsidian/src/build-storage.ts:160-165

Storage configuration options and parsed file formats

Option ParameterTypeDefault ValueDescription
outputPathRenameOutputFn | RenameOutputPreset'simple'Strategy or function used to transform output file paths ('simple' or 'ignore').
urlfunction(file) => /{normalized}Generates URLs for media files based on output paths.
enforceMdxbooleantrueForces all Markdown documents to use the .mdx extension.

Sources: packages/obsidian/src/build-storage.ts:11-30

Note

When enforceMdx is active, content files ending in .md have their output extension rewritten to .mdx after frontmatter extraction. Sources: packages/obsidian/src/build-storage.ts:111-113

AST traversal and text replacement

The remarkWikilinks transformer uses unist-util-visit to traverse the Markdown Abstract Syntax Tree (AST), targeting paragraph nodes to locate and replace Obsidian wikilink syntax. When processing a paragraph, it recursively visits child elements to inspect text nodes while maintaining parent stack references.

visit(tree, 'paragraph', (node, index, parent) => {
  if (typeof index !== 'number' || !parent) return;
 
  const replaceParagraph: RootContent[] = [node];
  let parents: Parent[] = [];
 
  function traverse(cur: RootContent) {
    if (cur.type === 'text') {
      const output = resolveParagraphText(cur.value, sourceFile, resolver);
      const idx = parents[0].children.indexOf(cur);
      let insertIdx = idx;
 
      parents[0].children.splice(insertIdx, 1);
      for (const item of output) {
        if (item.inParagraph) {
          parents[0].children.splice(insertIdx, 0, ...item.children);
          insertIdx += item.children.length;
        } else {
          replaceParagraph.push(...item.children);
          // clone parents
          parents = structuredClone(parents);
          // removed inserted elements
          parents[0].children.splice(idx, insertIdx - idx);
          insertIdx = idx;
          replaceParagraph.push(parents.at(-1) as Paragraph);
        }
      }
 
      return;
    }
 
    if ('children' in cur && Array.isArray(cur.children)) {
      parents.unshift(cur);
      for (const child of cur.children) {
        traverse(child);
      }
      parents.shift();
    }
  }
 
  traverse(node);
  parent.children.splice(index, 1, ...replaceParagraph);
});

Sources: packages/obsidian/src/remark/remark-wikilinks.ts:28-70

The text content of paragraphs is parsed using regular expressions designed to capture standard wikilinks and embedded assets. The RegexWikilink pattern matches both standard and embedded bracketed references across text strings.

const RegexWikilink = /!?\[\[(?<content>([^\]]|\\])+)]]/g;
const RegexContent =
  /^(?<name>(?:\\#|\\\||[^#|])*)(?:#(?<heading>(?:\\\||[^|])+))?(?:\|(?<alias>.+))?$/;

Sources: packages/obsidian/src/remark/remark-wikilinks.ts:15-17

Warning

Matching unescaped pipes or hash characters inside wikilink syntax incorrectly splits target paths, headings, and aliases. The RegexContent parser strictly separates name, heading, and alias parts using explicit group boundaries. Sources: packages/obsidian/src/remark/remark-wikilinks.ts:16-17

The resolveWikilink function processes extracted wikilink components based on whether the reference is an embedded asset (prefixed with !) or a standard hyperlink. It handles heading-only references, media files, and link aliases.

function resolveWikilink(
  isEmbed: boolean,
  content: string,
  sourceFile: ParsedFile,
  resolver: VaultResolver,
): RootContent | undefined {
  const match = RegexContent.exec(content);
  if (!match?.groups) return;
 
  const { name, heading, alias } = match.groups as {
    name: string;
    heading?: string;
    alias?: string;
  };
 
  const isHeadingOnly = name.length === 0 && heading;
 
  if (isEmbed) {
    const ref = isHeadingOnly ? sourceFile : resolver.resolveAny(name, sourceFile.path);
 
    if (!ref || ref.format === 'data') {
      console.warn(`failed to resolve ${name} wikilink`);
      return;
    }
 
    if (ref.format === 'content') {
      console.warn(
        'some features of embed content blocks are not supported yet, use at your own risk.',
      );
 
      return {
        type: 'mdxJsxFlowElement',
        name: 'include',
        attributes: [],
        children: [
          {
            type: 'paragraph',
            children: [
              {
                type: 'text',
                value: getFileHref(ref, sourceFile, heading),
              },
            ],
          },
        ],
      } satisfies MdxJsxFlowElement;
    }
 
    if (alias) console.warn('we do not support specifying image size like `![[image.png|300]].');
 
    return {
      type: 'image',
      url: ref.url ?? getFileHref(ref, sourceFile, heading),
      alt: name,
    };
  }
 
  let url: string;
 
  if (isHeadingOnly) {
    url = `#${getHeadingHash(heading)}`;
  } else {
    const ref = resolver.resolveAny(name, sourceFile.path);
 
    if (!ref) {
      console.warn(`failed to resolve ${name} wikilink`);
      return;
    }
 
    url = getFileHref(ref, sourceFile, heading);
  }
 
  return {
    type: 'link',
    url,
    data: {
      isWikiLink: true,
    },
    children: [
      {

Sources: packages/obsidian/src/remark/remark-wikilinks.ts:139-224

Link Property / ParameterRegular Expression GroupBehavior & Fallback
namenameResolves target file via resolver.resolveAny. Defaults to sourceFile if heading-only.
headingheadingAppends hash anchor via getHeadingHash or getFileHref.
aliasaliasSets display text for links (alias ?? content). Warns if image size syntax is specified.

Sources: packages/obsidian/src/remark/remark-wikilinks.ts:145-152, packages/obsidian/src/remark/remark-wikilinks.ts:187-221

Note

When resolving an embed content block (ref.format === 'content'), the transformer generates an MDX JSX flow element with the name 'include', wrapping the file reference in a paragraph. Sources: packages/obsidian/src/remark/remark-wikilinks.ts:164-184

Callout and block reference translation

Obsidian callout transformation

The remarkConvert transformer checks blockquote nodes to translate Obsidian-style callout blocks into structured components. It extracts the callout type and optional collapsible indicator using the RegexCalloutHead pattern.

const RegexCalloutHead = /^\[!(?<type>\w+)](?<collapsible>\+)?/;

Sources: packages/obsidian/src/remark/remark-convert.ts:10-10

The resolveCallout function inspects the first child paragraph of a blockquote for callout syntax. If matched, it strips the prefix, separates the title from the body content across newlines, and constructs a callout node via createCallout.

function resolveCallout(node: Blockquote) {
  const head = node.children[0];
  if (!head || head.type !== 'paragraph') return;
  const textNode = head.children[0];
  if (!textNode || textNode.type !== 'text') return;
 
  const match = RegexCalloutHead.exec(textNode.value);
  if (!match) return;
 
  textNode.value = textNode.value.slice(match[0].length).trimStart();
 
  const [title, rest] = separate(/\r?\n/, head.children) ?? [head.children];
  const body = node.children.slice(1);
  if (rest) {
    body.unshift({
      type: 'paragraph',
      children: rest as PhrasingContent[],
    });
  }
 
  return createCallout(
    match[1],
    [
      {
        type: 'paragraph',
        children: title as PhrasingContent[],
      },
    ],
    body,
  );
}

Sources: packages/obsidian/src/remark/remark-convert.ts:16-46

Warning

Blockquotes lacking a paragraph as their initial child or missing the [!type] header format are ignored by resolveCallout and remain standard blockquotes. Sources: packages/obsidian/src/remark/remark-convert.ts:17-23

Block ID parsing and section wrapping

The remarkBlockId plugin traverses paragraph nodes to extract Obsidian block references matching the trailing caret identifier pattern.

const Regex = /(?<!\\)\^(?<block_id>\w+)$/m;

Sources: packages/obsidian/src/remark/remark-block-id.ts:7-7

It visits child text nodes in reverse order, searching for block identifiers. When a valid ID is isolated, the plugin trims the trailing identifier string from the text node value and wraps the paragraph inside an MDX JSX flow element representing a section tag with an id attribute.

visit(tree, 'paragraph', (node) => {
  let id: string | undefined;
 
  visit(
    node,
    ['link', 'text', 'mdxJsxFlowElement'],
    (textNode) => {
      if (textNode.type !== 'text') return 'skip';
 
      const value = textNode.value;
      const match = Regex.exec(value);
      // if last text node isn't a block id, skip
      if (!match) return false;
 
      id = match[1];
      textNode.value =
        value.slice(0, match.index).trimEnd() + value.slice(match.index + match[0].length);
      return false;
    },
    true,
  );
 
  if (id) {
    replace(node, {
      type: 'mdxJsxFlowElement',
      name: 'section',
      attributes: [
        {
          type: 'mdxJsxAttribute',
          name: 'id',
          value: `^${id}`,
        },
      ],
      children: [
        {
          ...node,
        },
      ],
    } satisfies MdxJsxFlowElement);
  }
 
  return 'skip';
});

Sources: packages/obsidian/src/remark/remark-block-id.ts:11-53

Tip

Escaped carets (\^) are excluded by negative lookbehind in the regex, ensuring literal carets inside paragraph text do not inadvertently trigger block ID extraction. Sources: packages/obsidian/src/remark/remark-block-id.ts:7-7

Comment stripping and remark pipeline

Comment stripping mechanism

The remarkObsidianComment function implements a unified transformer that purges Obsidian comment syntax from the Abstract Syntax Tree. It uses a negative lookbehind regular expression delimiter /(?<!\\)%%/ to identify the opening and closing boundaries of comment blocks while preserving escaped percent sequences. Sources: packages/obsidian/src/remark/remark-obsidian-comment.ts:5-7

The internal removeComment helper function recursively processes arrays of root content nodes by calling separate with the delimiter. If both a starting and ending delimiter pair are successfully found, the content preceding the opening comment and any remaining nodes following the closing comment are spliced together, discarding all nested nodes residing within the comment boundaries. Sources: packages/obsidian/src/remark/remark-obsidian-comment.ts:8-17

Caution

If an opening comment delimiter lacks a corresponding closing delimiter in the node stream, removeComment halts processing and returns the original node array unmodified, preventing partial document truncation. Sources: packages/obsidian/src/remark/remark-obsidian-comment.ts:13-14

Remark transformation plugin suite

The getRemarkPlugins function composes the complete array of unified plugins used to transform Obsidian markdown vaults, accepting storage and resolver arguments to build the full processing pipeline. Sources: packages/obsidian/src/remark/index.ts:9-9

Plugin FunctionRequired OptionsProcessing Role
remarkWikilinks{ resolver, storage }Resolves internal wikilinks and embedded content references.
remarkConvertNoneTranslates Obsidian callout syntax within blockquotes.
remarkObsidianCommentNoneStrips %% comment blocks from document nodes.
remarkBlockIdNoneExtracts trailing caret block identifiers and wraps sections.

Sources: packages/obsidian/src/remark/index.ts:9-15

Was this page helpful?