Skip to content

ngtrio/mdian

Repository files navigation

mdian

npm version CI license

Obsidian Flavored Markdown support for unified, remark, rehype, and React Markdown.

mdian adds Obsidian-style links, embeds, callouts, highlights, comments, and anchor behavior to Markdown pipelines without taking over the rest of your Markdown stack. Use it with plain unified pipelines, or use the React preset when rendering with react-markdown.

Try the demo

Features

  • Unified-compatible remarkOfm and rehypeOfm plugins.
  • Wikilinks, note embeds, image/file embeds, highlights, comments, and callouts.
  • Heading and block anchor output that works with native browser hash navigation.
  • Optional YouTube and X/Twitter external embeds from standard Markdown image syntax.
  • Stable data-ofm-* attributes and ofm-* class names for app integrations.
  • Optional mdian/react preset for react-markdown components, note embeds, image URL rewriting, and X/Twitter enhancement.
  • Small optional stylesheet at mdian/styles.css.

Regular Markdown still works as usual. GFM, math, syntax highlighting, and other Markdown extensions are not bundled; add plugins such as remark-gfm, remark-math, or rehype-katex in your own pipeline.

Supported Syntax

Feature Examples
Wikilinks [[Page]], [[Page#Heading]], [[Page#^block-id]], [[Page|Alias]]
Embeds ![[Page]], ![[Page#Heading]], ![[Page#^block-id]], ![[image.png|500]]
Highlights ==important==
Comments %%hidden note%%
Callouts > [!warning] Caution
External embeds ![Video](https://www.youtube.com/watch?v=...), ![](https://x.com/user/status/...)

Installation

pnpm add mdian

For a complete unified HTML pipeline, install the surrounding unified packages you use in your app:

pnpm add unified remark-parse remark-rehype rehype-stringify

For React Markdown usage:

pnpm add mdian react react-markdown

mdian requires Node.js 18 or newer.

Quick Start

import rehypeStringify from 'rehype-stringify'
import {rehypeOfm, remarkOfm} from 'mdian'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import {unified} from 'unified'
import 'mdian/styles.css'

const source = [
  '# Project Notes',
  '',
  'See [[Roadmap|the roadmap]].',
  '',
  '> [!tip] Remember',
  '> You can highlight ==important== details.',
  '',
  '![[Architecture#Overview]]'
].join('\n')

const html = String(
  await unified()
    .use(remarkParse)
    .use(remarkOfm)
    .use(remarkRehype)
    .use(rehypeOfm, {
      resolveHref(href) {
        return href.startsWith('#') ? href : `/wiki/${href}`
      },
      resolvePathCandidates(path) {
        return path === 'Roadmap' ? ['docs/Roadmap'] : []
      }
    })
    .use(rehypeStringify)
    .process(source)
)

resolvePathCandidates(path) receives the raw OFM target path. When it returns one or more candidates, the first candidate becomes the canonical rendered target for data-ofm-path, title, and generated hrefs.

React Markdown

Use createOfmReactPreset from mdian/react when rendering with react-markdown. The preset wires the remark and rehype plugins together and adds components for wiki links, embeds, images, and supported external embeds.

import ReactMarkdown from 'react-markdown'
import {createOfmReactPreset} from 'mdian/react'
import remarkGfm from 'remark-gfm'
import 'mdian/styles.css'

const notes = new Map([
  ['Project Notes', {markdown: '# Project Notes\n\nHello world.', title: 'Project Notes'}]
])

const ofm = createOfmReactPreset({
  markdown: {
    remarkPlugins: [remarkGfm]
  },
  ofm: {
    rehype: {
      resolveHref(href) {
        return href.startsWith('#') ? href : `/wiki/${href}`
      },
      resolveResourceUrl(url) {
        return url.startsWith('assets/') ? `/${url}` : url
      },
      resolvePathCandidates(path) {
        return path === 'Project Notes' ? ['workspace/Project Notes'] : []
      }
    }
  },
  noteEmbed: {
    resolve({path}) {
      return notes.get(path) ?? {
        markdown: `# ${path}\n\nMissing note content.`,
        title: path
      }
    }
  },
  externalEmbeds: {
    twitter: {
      enhance: true
    }
  }
})

export function Markdown({markdown}: {markdown: string}) {
  return (
    <ReactMarkdown
      components={ofm.components}
      rehypePlugins={ofm.rehypePlugins}
      remarkPlugins={ofm.remarkPlugins}
    >
      {markdown}
    </ReactMarkdown>
  )
}

The React preset keeps application-specific behavior in your app:

  • markdown.remarkPlugins and markdown.rehypePlugins add shared Markdown extensions once.
  • ofm.rehype.resolveHref rewrites generated OFM link and embed hrefs.
  • ofm.rehype.resolveResourceUrl rewrites resource URLs for OFM image/file embeds.
  • wikiLink.render can replace the default <a> rendering with your router link.
  • noteEmbed.resolve resolves ![[note]] embeds to Markdown content.
  • noteEmbed.maxDepth limits recursive note embed rendering.
  • externalEmbeds.twitter.enhance enables the built-in X/Twitter widget enhancement path.

API

Package Exports

Import Exports
mdian remarkOfm, rehypeOfm, buildOfmSlugPath, ofmClassNames, ofmPublicKind, ofmPublicPropKeys, ofmPublicProvider, ofmPublicVariant, readOfmPublicProps
mdian/react createOfmReactPreset, loadTwitterWidgets, React preset types
mdian/styles.css Optional default styles for generated OFM markup

remarkOfm(options)

remarkOfm registers the micromark extensions, mdast bridges, and post-parse remark transforms for enabled OFM syntax. All syntax options default to true.

Option Syntax
wikilinks [[Page]], [[Page#Heading]], [[Page|Alias]]
embeds ![[Page]], ![[image.png|500]]
highlights ==important==
comments %%hidden note%%
callouts > [!note] Title
softBreaks Newline-to-<br> inside paragraphs (Obsidian default)

Extended syntax patterns:

Pattern Example Notes
Callout image metadata `> [!grid masonry]`

rehypeOfm(options)

rehypeOfm walks the hast tree once and applies OFM HTML transforms.

Option Type Default Effect
externalEmbeds {youtube?: boolean; twitter?: boolean} both enabled Toggle external Markdown image upgrades per provider.
resolveHref (href: string) => string undefined Resolve the final href for wikilinks and embed links.
resolveResourceUrl (url: string) => string undefined Resolve the final resource URL for image/file embeds.
resolvePathCandidates (path: string) => readonly string[] undefined Resolve a raw OFM target path to zero or more canonical candidate paths.

URL Helpers

buildOfmSlugPath(path) returns the canonical slug used for OFM page routes. Use it to align your router with the slugs mdian generates internally.

HTML Contract

Generated elements use stable public attributes accessible through ofmPublicPropKeys:

  • data-ofm-kind="wikilink"
  • data-ofm-kind="embed" with data-ofm-variant="note" | "image" | "file" | "external"
  • data-ofm-kind="embed" with data-ofm-provider="youtube" | "twitter" for external embeds
  • data-ofm-kind="callout" with data-ofm-callout, optional data-ofm-callout-metadata, and the boolean-presence attrs data-ofm-foldable / data-ofm-collapsed
  • data-ofm-kind="highlight"
  • data-ofm-kind="block-anchor" with data-ofm-block-id on paragraphs/list items carrying a block anchor

Additional metadata: data-ofm-path, data-ofm-alias, data-ofm-fragment.

Generated elements also use stable class names from ofmClassNames: ofm-wikilink, ofm-embed, ofm-external-embed, ofm-highlight, ofm-callout, ofm-callout-title, ofm-callout-content, ofm-heading-target, ofm-block-target, ofm-block-anchor-label.

Heading and block anchor targets render native HTML id attributes whose values match the canonical OFM fragment slug, so browser hash navigation works without custom JavaScript.

To consume these attributes in TypeScript with type-safe narrowing:

import {readOfmPublicProps} from 'mdian'

const props = readOfmPublicProps(element)
if (!props) return
switch (props.kind) {
  case 'wikilink':
    console.log('wikilink to', props.path, props.fragment)
    break
  case 'embed':
    if (props.variant === 'image') console.log('image at', props.path)
    if (props.variant === 'external') console.log('external embed', props.provider)
    break
  case 'callout':
    console.log('callout', props.calloutType, props.metadata)
    break
  case 'block-anchor':
    console.log('block anchor', props.blockId)
    break
  // highlight has no extra fields
}

Development

pnpm install
pnpm build
pnpm typecheck
pnpm test
pnpm demo:dev

Tests live in test/ and run against built output in dist/. The demo app in demo/ consumes the workspace package directly and is useful for checking parser and rendering behavior interactively.

Releasing

  • Regular feature and fix PRs do not change package.json.version.
  • To cut a release, merge the desired version bump to main, then run pnpm release.
  • pnpm release tags the current main commit as v<version> and pushes that tag to origin.
  • The publish workflow runs from that tag, verifies the package, publishes to npm, and creates the matching GitHub Release.

License

Apache-2.0. See LICENSE.