Markdown with components.

Readable as text, rendered as a page. Boxes, grids, values from data and inputs the reader changes, written in lines that still read as Markdown.

Open the playground Read the spec

launch.comp.md Loading the processor…

Launch week

2 of 3 done

13 points shipped

  • The parser

  • The playground

  • The website

Why CompMD

Markdown stops where pages start.

It has headings, lists and tables, but no way to put two things side by side, show a number from data, or let the reader change what the page shows.

The usual way out leaves Markdown behind.

MDX turns the document into a program that needs a build, and templates bury the text in markup. Either way, the file stops reading as text.

CompMD adds components that read as text.

A component is a line of its own, ::: box, with its params in braces. A CompMD processor renders it; any other Markdown tool shows readable text.

What it adds

Each example is a document and the page it renders, made by the processor when this site was built.

Components and slots

Define a component once, in CompMD itself: typed params, a body, and named slots the caller fills.

card.comp.md
```compmd component=card {padding=m border=subtle border-radius=m}
params:
  title: string
slots: [default, footer]
---
**{{title}}**

{{md.slot}}

{{md.slot.footer}}
```

::: card {title="Ship the beta"}
Three small tasks left.

:: #footer
*Due Friday.*
::: /card

Ship the beta

Three small tasks left.

Due Friday.

Data

Read CSV, JSON or YAML from the frontmatter, then filter, sort and add it up with expressions.

books.comp.md
---
md:
  data:
    - from: ./books.csv
      as: books
      schema: {title: string, author: string, year: number, pages: number}
---

::: each {in=(md.sortBy(md.data.books, "year")) as=b join=list}
- *{{b.title}}*, {{b.author}} ({{b.year}})
::: /each

:value[863 pages]{of=(md.number(md.sum(md.data.books.map(b, b.pages)), 0) + " pages")} in all.
  • Solaris, Stanisław Lem (1961)

  • The Dispossessed, Ursula K. Le Guin (1974)

  • Piranesi, Susanna Clarke (2020)

863 pages in all.

Inputs

Declare a value the reader changes, bind an input to it, and the page follows. Try it.

reading.comp.md
---
md:
  data:
    - from: ./books.csv
      as: books
      schema: {title: string, author: string, year: number, pages: number}
  inputs:
    since: number = 1970
---

:: number-input[Published since: 1970] {bind=since}

::: each {in=(md.data.books.filter(b, b.year >= md.input.since)) as=b join=list}
- *{{b.title}}* ({{b.year}})

:: #empty
No book is that recent.
::: /each
  • The Dispossessed (1974)

  • Piranesi (2020)

Layout

Grids, rows and stacks that follow the width of the box they are in, not the window.

plan.comp.md
::: grid {min=7rem gap=s}
::: box {padding=s background=subtle border-radius=m}
**Plan**
::: /box
::: box {padding=s background=subtle border-radius=m}
**Build**
::: /box
::: box {padding=s background=accent border-radius=m}
**Ship**
::: /box
::: /grid

Plan

Build

Ship

Components of your own

When CompMD isn't enough, write a component in JSX, TSX, Svelte or JavaScript. It runs in a sandbox, as this one does; a renderer that can't run it shows its fallback.

meter.comp.md
```compmd component=meter signature
params:
  value: number
  label: string
```

```jsx component=meter runtime=react@19
export default function Meter({ value, label }) {
  return (
    <label style={{ display: "grid", gap: 6 }}>
      <span>{label}: <strong>{value}%</strong></span>
      <progress max="100" value={value} style={{ width: "100%" }} />
    </label>
  );
}
```

:: meter[Docs: 72%] {label="Docs" value=72}
Docs: 72%

Still Markdown

A CompMD file is a Markdown file. On GitHub, in an editor's preview or in any Markdown renderer it reads well: each component shows its fallback text, and nothing turns into noise. Switch the renderer to see.

release.comp.md
---
title: Release notes
fixed: 14
---

# {{md.doc.title}}

::: box {padding=m background=subtle border-radius=m}
:value[14 bugs fixed]{of=(string(md.doc.fixed) + " bugs fixed")} since the last release.
::: /box

::: if {when=(md.doc.fixed > 10)}
A big one: read it before you upgrade.

:: #else
A small one.
::: /if

Release notes

14 bugs fixed since the last release.

A big one: read it before you upgrade.

Great for plain Markdown too

A document without a single component is a CompMD document, and gets everything the processor and the renderer give any document.

  • CommonMark and GFM as GitHub renders them: tables, task lists, footnotes and alerts.
  • A live preview that renders only the blocks an edit changed, and keeps the rest of the page as it was.
  • Every rendered block knows the lines it came from, so an editor can find the text behind the page.
trip.md
# Trip checklist

- [x] Book the train
- [ ] Pack the charger[^1]

| Day | City |
| --- | --- |
| Mon | Lyon |
| Tue | Turin |

> [!TIP]
> Check the strike calendar the night before.

[^1]: The long cable.

Trip checklist

  • Book the train

  • Pack the charger1

DayCity
MonLyon
TueTurin

Tip

Check the strike calendar the night before.


  1. The long cable.

    ↩

Use it in your app

Two packages: the processor, which turns text into a tree, and the renderer, which turns the tree into a page.

npm install @compmd/processor @compmd/render

Not on npm yet. Until they are, the packages are in the repository: wasm/ and render/.

Process a document and render it into an element.

import { init, process } from "@compmd/processor";
import { createPage } from "@compmd/render";

await init();

const text = `# Hello

::: box {padding=m border=subtle border-radius=m}
A box, written in **CompMD**.
::: /box
`;
const { expanded } = process({ path: "hello.comp.md", files: { "hello.comp.md": text }, now: new Date().toISOString() });
createPage(document.body).render(expanded);

Safe to open

A CompMD document can come from anyone. Opening one can't run code in your page, reach your data, or call home.

The threat

A document from a stranger, or a pack it imports, that tries to run code in the reader's page, read the reader's data or session, send data out, run forever, or act in the reader's name.

What stops it

  • Components written in CompMD run no code at all: only Markdown, params and slots.
  • Code in JSX, Svelte or JavaScript runs sandboxed. It sees only its params and slots, with no network, no storage or cookies, no navigation, and it is stopped when it runs too long.
  • Data comes only from origins the reader or the host allows, never ones a document grants itself.
  • Text is escaped, and raw HTML is limited to a short list of safe tags.
  • Expressions run within a work budget, and expansion within a size limit, so no document can hang the page.

How this implementation builds it

  • The playground renders in a sandboxed frame with an opaque origin, under a content security policy that allows only its own files.
  • The processor counts the work of each expression, limits the size of the expansion, and refuses recursion.

The spec's section 15 says what every implementation must do.

Under the hood

A processor in Rust, run as WebAssembly
The same processor runs natively and in the browser, where it is 808 KB gzipped. The processor
Incremental
A keystroke in a 500 KB document is processed in 0.30 ms (p95, Chrome on an Apple M4): only the blocks an edit touches are processed again. How
Diagnostics with fixes, hints at the caret
Every mistake has a code, a place and, where one is clear, the edit that fixes it. At the caret, the processor says what a name, param or path is and what may be written there.
Expressions in CEL
A profile of CEL, implemented from scratch: type-checked before it runs, evaluated within a work budget, with CompMD's md. functions for dates, numbers and lists.
A conformance set
177 cases, with every diagnostic code, that any implementation can run against its own processor. The cases