npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

remark-obsidian-mdx

v1.1.1

Published

Remark plugin to support Obsidian markdown syntax

Downloads

118

Readme

remark-obsidian-mdx

This plugin is inspired by remark-obsidian

Read the blog post: How I'm Writing MDX with Obsidian

Version License

Remark plugin to support Obsidian markdown syntax with MDX output.

A blog built with this plugin is available at https://english.mjstudio.net, and you can see a real-world usage example at https://github.com/mym0404/english-blog.

preview

Features

  • > [!CALLOUT] to <Callout ...> (MDX JSX flow element)
  • ==highlight== to <mark>...</mark> (MDX JSX text element)
  • [[Wiki link]] to mdast link nodes (alias divider is |)
  • [[#Heading]] uses a heading slug
  • ![[Embed]] to user-provided MDX JSX nodes (note/image/video renderers)
  • ![[image.png|alt text]] supports custom alt text for image embeds
  • Match notes, embeddings from the contentRoot recursively(mocking Obsidian's algorithm). You don't need to put entire path of resources. Just write [[img.png]]

Installation

pnpm add -D remark-obsidian-mdx

Usage

MDX pipeline

import { compile } from "@mdx-js/mdx";
import remarkObsidianMdx from "remark-obsidian-mdx";

const result = await compile(source, {
  remarkPlugins: [remarkObsidianMdx],
});

If your MDX runtime does not provide a default Callout component, register it in your components map (for example, Callout from Fumadocs).

Unified to HTML

import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import remarkObsidianMdx from "remark-obsidian-mdx";

const { value } = unified()
  .use(remarkParse)
  .use(remarkObsidianMdx)
  .use(remarkRehype, {
    allowDangerousHtml: true,
    passThrough: [
      "mdxjsEsm",
      "mdxFlowExpression",
      "mdxJsxFlowElement",
      "mdxJsxTextElement",
      "mdxTextExpression",
    ],
  })
  .use(rehypeStringify, { allowDangerousHtml: true })
  .processSync("[[Hello world]]");

passThrough keeps MDX nodes intact when converting to HAST; without it, MDX JSX nodes are dropped.

Fumadocs ready-to-use setup

The examples below are taken from a working Fumadocs project and are ready to copy.

1) source.config.ts

import remarkObsidianMdx, { type PluginOptions } from "remark-obsidian-mdx";

export const docs = defineDocs({ ... });

export default defineConfig({
  mdxOptions: {
    remarkPlugins: [
      [
        remarkObsidianMdx,
        {
          contentRoot: "./content",
          contentRootUrlPrefix: "",
          wikiLinkPathTransform: ({ resolvedUrl }) =>
            resolvedUrl?.replace("/content", ""),
          embeddingPathTransform: ({ resolvedUrl }) =>
            resolvedUrl?.replace("/content", ""),
          callout: {
            componentName: "Callout",
            typePropName: "type",
            defaultType: "info",
          },
          embedRendering: {},
        } satisfies PluginOptions,
      ],
      remarkMath,
    ],
    rehypePlugins: (v) => [rehypeKatex, ...v],
  },
});

2) Assets route (content/assets/* -> /assets/*)

import fs from "node:fs/promises";
import path from "node:path";
import { NextRequest } from "next/server";

export const runtime = "nodejs";

const ASSET_ROUTE_PREFIX = "/assets/";
const ASSET_ROOT = path.resolve(process.cwd(), "content", "assets");

const toAssetPath = ({ pathname }: { pathname: string }) => {
  if (!pathname.startsWith(ASSET_ROUTE_PREFIX)) {
    return null;
  }

  const encodedPath = pathname.slice(ASSET_ROUTE_PREFIX.length);
  if (!encodedPath) {
    return null;
  }

  let decodedPath = "";
  try {
    decodedPath = decodeURIComponent(encodedPath);
  } catch {
    return null;
  }

  const resolvedPath = path.resolve(ASSET_ROOT, decodedPath);
  const withinRoot =
    resolvedPath === ASSET_ROOT || resolvedPath.startsWith(`${ASSET_ROOT}${path.sep}`);

  if (!withinRoot) {
    return null;
  }

  return resolvedPath;
};

const getContentType = ({ extension }: { extension: string }) => {
  switch (extension) {
    case "apng":
      return "image/apng";
    case "avif":
      return "image/avif";
    case "gif":
      return "image/gif";
    case "jpeg":
      return "image/jpeg";
    case "jpg":
      return "image/jpeg";
    case "png":
      return "image/png";
    case "svg":
      return "image/svg+xml";
    case "webp":
      return "image/webp";
    case "m4v":
      return "video/x-m4v";
    case "mov":
      return "video/quicktime";
    case "mp4":
      return "video/mp4";
    case "ogv":
      return "video/ogg";
    case "webm":
      return "video/webm";
    case "pdf":
      return "application/pdf";
    default:
      return "application/octet-stream";
  }
};

type ErrorWithCode = Error & { code?: string };

const isErrorWithCode = (value: unknown): value is ErrorWithCode =>
  value instanceof Error && "code" in value;

const createNotFoundResponse = () =>
  new Response("Not found", { status: 404 });

export const GET = async (request: NextRequest) => {
  const assetPath = toAssetPath({ pathname: request.nextUrl.pathname });
  if (!assetPath) {
    return createNotFoundResponse();
  }

  try {
    const file = await fs.readFile(assetPath);
    const extension = path.extname(assetPath).slice(1).toLowerCase();
    const contentType = getContentType({ extension });

    return new Response(file, {
      status: 200,
      headers: {
        "Content-Type": contentType,
        "Cache-Control": "public, max-age=3600",
      },
    });
  } catch (error) {
    if (isErrorWithCode(error) && error.code === "ENOENT") {
      return createNotFoundResponse();
    }

    return new Response("Failed to read asset", { status: 500 });
  }
};

3) Content layout

  • content/docs for docs
  • content/blog for blog posts
  • content/assets for images/video/etc served under /assets

Options

Example

import remarkObsidianMdx from "remark-obsidian-mdx";

remark().use(remarkObsidianMdx, {
  callout: {
    componentName: "Callout",
    typePropName: "type",
    defaultType: "info",
    typeMap: {
      note: "info",
      abstract: "info",
      summary: "info",
      tldr: "info",
      info: "info",
      todo: "info",
      quote: "info",
      tip: "idea",
      hint: "idea",
      example: "idea",
      question: "idea",
      warn: "warn",
      warning: "warn",
      caution: "warn",
      attention: "warn",
      danger: "error",
      error: "error",
      fail: "error",
      failure: "error",
      bug: "error",
      success: "success",
      done: "success",
      check: "success",
      idea: "idea",
    },
  },
  contentRoot: "/vault",
  contentRootUrlPrefix: "/blog",
  embedRendering: {
    note: ({ target }) => ({
      type: "mdxJsxFlowElement",
      name: "EmbedNote",
      attributes: [
        { type: "mdxJsxAttribute", name: "page", value: target.page },
      ],
      children: [],
    }),
    image: ({ target, resolvedUrl, imageWidth, imageHeight, alias }) => ({
      type: "image",
      url: resolvedUrl ?? target.page,
      alt: alias || "",
      data: {
        hProperties: {
          width: imageWidth ?? 640,
          height: imageHeight ?? 480,
        },
      },
    }),
    video: ({ target, resolvedUrl }) => ({
      type: "mdxJsxFlowElement",
      name: "video",
      attributes: [
        { type: "mdxJsxAttribute", name: "src", value: resolvedUrl ?? target.page },
      ],
      children: [],
    }),
  },
  embeddingPathTransform: ({ kind, resolvedUrl }) => {
    if (kind === "image" || kind === "video") {
      return resolvedUrl ?? null;
    }
    return null;
  },
  wikiLinkPathTransform: ({ resolvedUrl }) => {
    if (!resolvedUrl) {
      return null;
    }
    return resolvedUrl.replace("/notes/", "/docs/");
  },
});

callout

  • typeMap fully replaces the default mapping when provided.
  • typeMap keys are normalized to lowercase.
  • Empty mapped values fall back to defaultType.

contentRoot

  • Required. Builds an on-disk index for resolving [[...]] and ![[...]].
  • Also passed to embed rendering for resolution checks.

contentRootUrlPrefix

  • Prepends a URL prefix for resolved paths without changing contentRoot.
  • Example: contentRoot: "/vault/.content" with contentRootUrlPrefix: "/blog" resolves [[ai-revolution]] to /blog/ai-revolution.

embedRendering

  • Controls how ![[...]] is rendered. Heading (#) and block (^) embeds are ignored.
  • Unsupported embed types (non-note/image/video files) are ignored.
  • Receives resolvedUrl, imageWidth/imageHeight, and alias when available.
  • For embeds, resolvedUrl includes extensions by default.
  • If a target cannot be resolved under contentRoot, the default output is a plain text fallback. You can override this with embedRendering.notFound.
  • If embedRendering.image is omitted, the plugin emits a standard image node with data.hProperties.width/height inferred from the file and alt set from the alias (e.g., ![[image.png|My alt text]]).
  • If embedRendering.video is omitted, it emits a video MDX JSX node.

embeddingPathTransform

  • Overrides resolved URLs for embeds based on embed kind.
  • Returning a string overrides resolvedUrl.

wikiLinkPathTransform

  • Overrides resolved URLs for [[...]] links.
  • Returning a string overrides resolvedUrl.
  • For wiki links, resolvedUrl excludes extensions by default.

Asset url mapping

If your vault stores images under vault/assets/images, you should serve them via a route like app/assets/[[slug]].tsx so the resolved URLs can be fetched by the app.

License

This project is licensed under the GNU GPL v3.0 - see the LICENSE.txt file for details.