Setting Up markdown-it

This guide uses markdown-it, a CommonMark-compliant parser with a large plugin ecosystem.

1. Install

bun add markdown-it
bun add -d @types/markdown-it

2. Create a shared renderer module

Create a single module shared by both client and server:

// renderer.ts
import MarkdownIt from 'markdown-it';

/**
 * Convert a JSON string to HTML attributes.
 * Used by widget and router-slot fenced blocks.
 */
function jsonToAttrs(text: string): string {
  const trimmed = text.trim();
  if (!trimmed) return '';
  try {
    const obj = JSON.parse(trimmed) as Record<string, unknown>;
    return Object.entries(obj)
      .map(([k, v]) => ` ${k}="${String(v).replace(/"/g, '&quot;')}"`)
      .join('');
  } catch {
    return '';
  }
}

const md = new MarkdownIt({
  html: false, // Escape raw HTML tags (safe for untrusted content)
});

// Override the fence rule to handle widget and router-slot blocks.
const defaultFence = md.renderer.rules.fence!;
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
  const token = tokens[idx];
  const lang = token.info.trim();
  const content = token.content;

  // ```widget:counter → <widget-counter start="42">
  if (lang.startsWith('widget:')) {
    const name = lang.slice('widget:'.length);
    const attrs = jsonToAttrs(content);
    return `<widget-${name}${attrs}></widget-${name}>`;
  }

  // ```router-slot → <router-slot>
  if (lang === 'router-slot') {
    const attrs = jsonToAttrs(content);
    return `<router-slot${attrs}></router-slot>`;
  }

  return defaultFence(tokens, idx, options, env, self);
};

export function render(markdown: string): string {
  return md.render(markdown);
}

The fence rule override intercepts fenced blocks:

```router-slot
```

becomes <router-slot>, and

```widget:counter
{"key": "value"}
```

becomes <widget-counter>. The JSON body (if present) is parsed into HTML attributes.

3. Server setup

// server.ts
import { Emroute } from '@emkodev/emroute/server';
import { BunFsRuntime } from '@emkodev/emroute/runtime/bun/fs';
import { render } from './renderer.ts';

const appRoot = import.meta.dirname!;

const runtime = new BunFsRuntime(appRoot);

const emroute = await Emroute.create({
  markdownRenderer: { render },
}, runtime);

Bun.serve({
  port: 1420,
  async fetch(req) {
    const response = await emroute.handleRequest(req);
    if (response) return response;
    return new Response('Not Found', { status: 404 });
  },
});

4. Client setup

// main.ts
import { bootEmrouteApp, MarkdownElement } from '@emkodev/emroute/spa';
import { render } from './renderer.ts';

MarkdownElement.setRenderer({ render });

await bootEmrouteApp();

setRenderer() must be called before any <mark-down> elements are connected to the DOM.

5. HTML passthrough

markdown-it controls raw HTML via the html option:

// Escape raw HTML tags (default, safe for untrusted content)
const md = new MarkdownIt({ html: false });

// Pass through raw HTML (trusted content only)
const md = new MarkdownIt({ html: true });

When html: true, any HTML in the markdown source is included verbatim. This is useful for embedding custom elements in .page.md files, but is a security risk with untrusted content.

6. Bundle size

markdown-it has a larger footprint than marked (~362KB vs ~129KB bundled). If bundle size is a concern and you don't need the plugin ecosystem, consider marked instead.