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
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.

Features
> [!CALLOUT]to<Callout ...>(MDX JSX flow element)==highlight==to<mark>...</mark>(MDX JSX text element)[[Wiki link]]to mdastlinknodes (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-mdxUsage
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/docsfor docscontent/blogfor blog postscontent/assetsfor 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
typeMapfully replaces the default mapping when provided.typeMapkeys 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"withcontentRootUrlPrefix: "/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, andaliaswhen available. - For embeds,
resolvedUrlincludes extensions by default. - If a target cannot be resolved under
contentRoot, the default output is a plain text fallback. You can override this withembedRendering.notFound. - If
embedRendering.imageis omitted, the plugin emits a standardimagenode withdata.hProperties.width/heightinferred from the file andaltset from the alias (e.g.,![[image.png|My alt text]]). - If
embedRendering.videois omitted, it emits avideoMDX 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,
resolvedUrlexcludes 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.
