Remark plugin to support Obsidian markdown syntax
npm install remark-obsidian-mdx> This plugin is inspired by remark-obsidian
> Read the blog post: How I'm Writing MDX with Obsidian


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.
- > [!CALLOUT] to (MDX JSX flow element)
- ==highlight== to ... (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]]
``bash`
pnpm add -D remark-obsidian-mdx
`js
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).
`js
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.
The examples below are taken from a working Fumadocs project and are ready to copy.
`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],
},
});
`
`ts
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 });
}
};
`
- content/docs for docscontent/blog
- for blog postscontent/assets
- for images/video/etc served under /assets
`js
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/");
},
});
`
- typeMap fully replaces the default mapping when provided.typeMap
- keys are normalized to lowercase.defaultType
- Empty mapped values fall back to .
- Required. Builds an on-disk index for resolving [[...]] and ![[...]].
- Also passed to embed rendering for resolution checks.
- Prepends a URL prefix for resolved paths without changing contentRoot.contentRoot: "/vault/.content"
- Example: with contentRootUrlPrefix: "/blog" resolves [[ai-revolution]] to /blog/ai-revolution.
- Controls how ![[...]] is rendered. Heading (#) and block (^) embeds are ignored.resolvedUrl
- Unsupported embed types (non-note/image/video files) are ignored.
- Receives , imageWidth/imageHeight, and alias when available.resolvedUrl
- For embeds, includes extensions by default.contentRoot
- If a target cannot be resolved under , the default output is a plain text fallback. You can override this with embedRendering.notFound.embedRendering.image
- If 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]]).embedRendering.video
- If is omitted, it emits a video MDX JSX node.
- Overrides resolved URLs for embeds based on embed kind.
- Returning a string overrides resolvedUrl.
- Overrides resolved URLs for [[...]] links.resolvedUrl
- Returning a string overrides .resolvedUrl
- For wiki links, excludes extensions by default.
, you should serve them via a route like app/assets/[[slug]].tsx` so the resolved URLs can be fetched by the app.This project is licensed under the GNU GPL v3.0 - see the LICENSE.txt file for details.