Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

xmd

xmd renders executable Markdown. You write a normal Markdown document, mark some code blocks as runnable, and xmd turns it into HTML — optionally running those code blocks and inserting their output, the way a Jupyter notebook or a Quarto document does.

Python, R, raw HTML, and Observable JS work out of the box, and xmd is built so you can add your own block types and ways of running code.

A first look

A document, hello.xmd:

---
title: Hello
---

# Hello

The time, computed when this page is built:

```{python}
from datetime import datetime
print(datetime.now())
```

Render it to HTML and run the code:

xmd render hello.xmd --run

You get an HTML page with the heading, the prose, the Python source, and the printed time below it. Drop --run and you get the same page with the code shown but not executed.

Install

Not yet on npm — use one of the routes below.

As a global xmd command (the usual case — runs through Node):

# from a checkout of this repo
npm install        # installs dependencies and builds the project
npm link           # puts `xmd` on your PATH

The .xmd document

An .xmd file is Markdown plus a few additions.

Frontmatter — optional YAML metadata between --- fences at the very top:

---
title: My Document
author: Jane
---

Cells — a fenced code block whose info string is a language in braces is a cell: something xmd can render and (with --run) execute.

```{python}
print("hello")
```

Cells take options on lines beginning with #|:

```{python}
#| echo: false      # hide the source; show only the output
#| eval: false      # show the source; do not run it
print("hello")
```

Built-in cell languages: python, r, html (emitted as raw HTML), and ojs (Observable JS, which runs in the browser).

Inline code`{python} 6 * 7` inside a sentence runs the expression and drops its value into the text, e.g. "the answer is 42".

Callouts and columns::: "directive" blocks become HTML containers:

:::callout-note
A note box.
:::

::::columns
:::column
left
:::
:::column
right
:::
::::

Anything else is ordinary Markdown (headings, lists, links, GFM, etc.).

Running code

By default, rendering does not run anything — cells are just displayed. To execute them, add --run (CLI) or use renderAsync (library). When you do, xmd runs the cells top to bottom, captures what each one prints, and inserts that output beneath the cell. A cell that produces a figure (e.g. matplotlib) shows the image.

How code runs is controlled by a runtime, which you set in your config (next section). Three things you can control:

  • Which interpreter. By default xmd calls python3, Rscript, etc. from your PATH. You can point a language at a specific interpreter instead — a virtualenv, conda run, a custom path.
  • Whether cells share state. By default each cell runs in a fresh process, so a variable set in one cell isn't visible in the next. A stateful runtime keeps one process alive per language, so cells share variables — like a notebook.
  • Different backends per language. You can run, say, Python in a container and shell scripts locally.

With no configuration, --run uses a fresh-process local runtime and the interpreters on your PATH.

Configuration — xmd.config.ts

To customize xmd, create a file named xmd.config.ts in the directory you run xmd from (your project root). The CLI looks for it there automatically — there is no flag to pass. (xmd.config.mjs and xmd.config.js also work.)

It exports a default object with two optional fields, handlers and runtimes:

// xmd.config.ts
import { createStatefulSubprocessAdapter } from "@xmd/engine";

export default {
  handlers: [],   // custom block types — see "Adding a block type"
  runtimes: [     // how code runs
    createStatefulSubprocessAdapter({
      commands: { python: "./.venv/bin/python" }, // use this project's virtualenv
    }),
  ],
};

With that file present, xmd render doc.xmd --run runs Python cells with your venv and keeps state across cells.

Adding a block type

A handler owns one {name} block type and turns it into HTML. The built-in python/r/html/ojs cells are just handlers that ship pre-registered; you add your own the same way.

A minimal handler that adds a {badge} block:

import type { BlockHandler } from "@xmd/engine";

const badge: BlockHandler = {
  name: "badge",                 // handles blocks tagged {badge}
  toHtml: (ctx) => `<span class="badge">${ctx.content}</span>`,
};

ctx gives the handler the block's content (its text), options (the parsed #| values), and the document's frontmatter.

Register it by adding it to handlers in your xmd.config.ts:

export default { handlers: [badge] };

Now blocks tagged {badge} render through your handler. (If your handler's name matches a built-in one, yours takes over and xmd logs a warning.)

A handler can also execute its block by implementing build (for cells) or inline (for inline code) — that's how the Python handler runs Python. The built-in pythonHandler source is the reference example.

Adding a way to run code

A runtime adapter knows how to execute one or more languages. The built-in adapters run local processes; you can write your own — to run code in a container, on a remote service, in the browser — by implementing this shape:

interface RuntimeAdapter {
  name: string;
  supports(language: string): boolean;                  // which languages it handles
  exec(req: { language: string; code: string }): Promise<{
    ok: boolean; stdout: string; stderr: string;
  }>;                                                   // run one cell, return its output
  createSession?(language: string): RuntimeSession;     // optional: keep state across cells
}

Add adapters under runtimes in xmd.config.ts. When several are registered, each language goes to the first adapter whose supports() returns true, so you can mix backends per language.

Using it as a library

import { createBaselineEngine, createLocalSubprocessAdapter } from "@xmd/engine";

const engine = createBaselineEngine();      // python/r/html/ojs pre-registered
// engine.registerBlock(badge);             // add your own handler
// engine.registerRuntime(myAdapter);       // add your own runtime

const src = "# Hi\n\n```{python}\nprint(2 + 2)\n```";

const shown = engine.render(src);                       // parse + render, no execution
const ran   = await engine.renderAsync(src, {           // execute cells, insert output
  runtime: createLocalSubprocessAdapter(),
});

console.log(ran.html);          // the rendered HTML string
console.log(ran.frontmatter);   // the parsed frontmatter object

Use render when you only need the document rendered; use renderAsync (with a runtime) when you want cells executed.

CLI

xmd render <file.xmd>               # render to HTML (no execution)
xmd render <file.xmd> --run         # also execute cells and insert their output
xmd render <file.xmd> --frontmatter # print the parsed frontmatter as JSON
xmd render <file.xmd> --mode=NAME   # pass an opaque "mode" string to handlers

The CLI registers the built-in handlers and loads xmd.config.ts from the current directory. --mode is only relevant if you write handlers that branch on it; otherwise ignore it.

API reference

Engine

Export What it is
createBaselineEngine(): XmdEngine An engine with the built-in python/r/html/ojs handlers registered.
new XmdEngine() An empty engine — no handlers registered.
engine.render(src, { mode? }) Parse and render to { html, frontmatter }. Does not execute code.
engine.renderAsync(src, { runtime?, runtimes?, mode? }) Like render, but executes cells through the given runtime(s). Returns a Promise.
engine.registerBlock(handler) Add (or override) a block-type handler. Returns the engine.
engine.registerRuntime(adapter) Add a runtime adapter, routed by language. Returns the engine.

Built-in handlers (to compose your own engine)

Export What it is
pythonHandler, rHandler Executable cells; honor echo/eval/output and inline code.
htmlHandler Emits the block's content as raw HTML.
ojsHandler Emits an Observable JS mount (runs in the browser).
makeLanguageCell(name, language, inlineWrap?) Build a python-style cell handler for any language.

Runtimes

Export What it is
createLocalSubprocessAdapter(opts?) Runs each cell as a fresh subprocess (stateless). opts: commands (per-language interpreter, string or argv array), cwd, env.
createStatefulSubprocessAdapter(opts?) Keeps a process alive per language so cells share state (python/node). Same opts.
localSubprocessAdapter A ready-made default local adapter (PATH interpreters).

Types (for TypeScript users): BlockHandler, BlockContext, RuntimeAdapter, RuntimeSession, ExecRequest, ExecResult, RenderResult, RenderOptions. Their exact shapes are in the bundled .d.ts files.

Develop

npm run build         # compile TypeScript to dist/ (+ type declarations)
npm test              # run the test suite (node:test)
npm run build:binary  # compile a standalone `xmd` executable (needs bun)

See examples/ for runnable documents, including a custom-handler example.

Notes

  • r cells need Rscript installed on the host.
  • ojs runs in the browser, so it emits a client-side mount rather than executing during the build.

License

MIT

Releases

Packages

Contributors

Languages