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 it in the playground Read the spec
Thursday Book Club
We meet on the last Thursday of the month, at Ana's. Pick a cover to see what we made of it.
Klara and the Sun, Kazuo Ishiguro. We're halfway through. Bring your questions on Thursday.
Not rated yet.
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.
```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.
---
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[1,945 pages]{of=(md.number(md.sum(md.data.books.map(b, b.pages)), 0) + " pages")} in all.
Pride and Prejudice, Jane Austen (1813)
A Gentleman in Moscow, Amor Towles (2016)
Piranesi, Susanna Clarke (2020)
Klara and the Sun, Kazuo Ishiguro (2021)
Project Hail Mary, Andy Weir (2021)
1,945 pages in all.
Inputs
Declare a value the reader changes, bind an input to it, and the page follows. Try it.
---
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
A Gentleman in Moscow (2016)
Piranesi (2020)
Klara and the Sun (2021)
Project Hail Mary (2021)
Layout
Grids, rows and stacks that follow the width of the box they are in, not the window.
::: 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.
```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}
Code renderers
A component can draw the code blocks of a language, such as Mermaid diagrams. The document keeps an ordinary code block, which GitHub and other renderers show as they always have.
---
md:
import:
- from: ./minimermaid.js
names: [minimermaid]
---
```compmd component=minimermaid signature
renders: mermaid
params:
theme: default | dark | neutral = default
```
```mermaid {alt="Draft, then review, then published"}
graph TD; Draft --> Review --> Published
```
graph TD; Draft --> Review --> PublishedComponents in a table cell
An inline component goes wherever text goes, a table cell too: each city's week drawn by a component in a file beside the document.
---
md:
import:
- from: ./spark.js
names: [spark]
---
| City | This week | Today |
| --- | --- | --- |
| Lisbon | :spark[16–22 °C]{values=[17, 19, 22, 21, 18, 16, 20]} | 20 °C |
| Oslo | :spark[3–9 °C]{values=[3, 5, 4, 7, 9, 6, 6]} | 6 °C |
| Cairo | :spark[24–31 °C]{values=[24, 27, 31, 30, 28, 29, 29]} | 29 °C |
```compmd component=spark signature
params:
values: array
```
| City | This week | Today |
|---|---|---|
| Lisbon | 16–22 °C | 20 °C |
| Oslo | 3–9 °C | 6 °C |
| Cairo | 24–31 °C | 29 °C |
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.
# Team offsite
We meet in **Lisbon on 12–13 June**. Flights are booked,
and the hotel is a short walk from the venue.
::: grid {min=12rem gap=m}
**Thursday.** Arrive by noon, then the roadmap
in the afternoon.
**Friday.** Workshops in the morning, and the
afternoon is yours.
::: /grid
::: box {background=accent padding=m border-radius=m}
Bring your laptop charger: the venue has none to lend.
::: /box
> [!NOTE]
> Questions go to Maya.
Team offsite
We meet in Lisbon on 12–13 June. Flights are booked, and the hotel is a short walk from the venue.
Thursday. Arrive by noon, then the roadmap in the afternoon.
Friday. Workshops in the morning, and the afternoon is yours.
Bring your laptop charger: the venue has none to lend.
Note
Questions go to Maya.
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 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.
Use it in your app
A Vite plugin and a line of configuration. Your documents get components of your own and from packs, each run at the trust you give it.
npm install @compmd/vite @compmd/processor @compmd/sandbox An early stage: versions are 0.x while the spec is a draft, and until version 1 a release can break documents and code written for the one before.
On npm: @compmd/vite, @compmd/processor, @compmd/sandbox and @compmd/render; in Rust, the crate compmd, and for a desktop app tauri-plugin-compmd. What each release changed: the releases and the spec's changelog, and GitHub's Watch → Releases to hear of the next.
The plugin gives the processor your packs and serves the sandbox; the page opens a document and renders it in a sandboxed frame.
import { compmd } from "@compmd/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [compmd()],
});
import { init, OpenDocument } from "@compmd/processor";
import { createSandbox } from "@compmd/sandbox";
import { request, sandbox } from "virtual:compmd";
import text from "./notes.comp.md?raw";
await init();
// The document, open with what the app gives it: its packs, from compmd.config.json.
let inputs: Record<string, unknown> = {};
const open = () =>
new OpenDocument({ ...request, path: "notes.comp.md", files: { "notes.comp.md": text }, now: new Date().toISOString(), inputs });
let opened = open();
// Its page, in a sandboxed frame.
const page = document.getElementById("page") as HTMLElement;
const view = createSandbox(sandbox).open(page, { components: opened.components });
view.render(opened.expanded);
// A reader changed an input: open the document again with its new value.
view.on("set", ({ input, value }) => {
inputs = { ...inputs, [input]: value };
opened.free();
opened = open();
view.render(opened.expanded);
});
Your components are a pack folder: a signature for each, and one line of configuration that trusts them. This site's, below.
{
"$schema": "../vite/compmd.config.schema.json",
"packs": {
"./app": { "trust": "trusted", "ambient": true }
},
"libraries": ["d3"]
}
---
md:
import:
- from: ./Stars.svelte
names: [{ name: Stars, as: stars }, { name: Stars, as: rating }]
export:
pack: "@site/app"
version: "1.0.0"
names: [stars, rating]
---
```compmd component=stars signature
params:
value: input<number>
of: number = 5
```
```compmd component=rating signature
params:
value: number
of: number = 5
readonly: boolean = true
```
<!-- A rating of `of` stars, `value` of them filled. As `stars`, a click sets the input `value` is bound to; as `rating`,
`readonly`, it only shows a value, from data. -->
<script lang="ts">
interface Props {
value: number;
of: number;
readonly?: boolean;
compmd: { set(param: string, value: unknown): void; fallback: { text: string } };
}
let { value, of, readonly = false, compmd }: Props = $props();
</script>
{#if readonly}
<span class="stars" role="img" aria-label="Rated {value} of {of}">
{#each { length: of } as _, i (i)}<span aria-hidden="true">{i < value ? "★" : "☆"}</span>{/each}
</span>
{:else}
<span class="stars" role="radiogroup" aria-label={compmd.fallback.text}>
{#each { length: of } as _, i (i)}
<button type="button" role="radio" aria-checked={i + 1 === value} aria-label="{i + 1} of {of}" onclick={() => compmd.set("value", i + 1)}>
{i < value ? "★" : "☆"}
</button>
{/each}
</span>
{/if}
<style>
.stars button,
.stars > span {
padding: 0 2px;
border: 0;
background: none;
color: var(--accent, #3557c4);
font-size: 1.6rem;
line-height: 1;
}
.stars button {
cursor: pointer;
}
</style>
Keep a document open: each edit processes and renders only the blocks it touches.
import { init, OpenDocument } from "@compmd/processor";
import { createPage } from "@compmd/render";
await init();
const open = new OpenDocument({ path: "notes.comp.md", files: { "notes.comp.md": "# Hello world\n" }, now: new Date().toISOString() });
const page = createPage(document.body);
page.render(open.expanded);
// The reader selected `world` and typed `there`: only the heading is processed and rendered again.
page.splice([open.edit(8, 13, "there").splice]);
Your own components, live
This site's stars, a Svelte component from its own pack folder, trusted, so it mounts in this page. The document binds it to an input. Click a star.
---
md:
inputs:
rating: number = 4
---
How was the talk? :value[4]{of=(string(md.input.rating))} of 5.
:: stars[4 of 5] {value=rating}
How was the talk? 4 of 5.
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 898 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
- 209 cases, with every diagnostic code, that any implementation can run against its own processor. The cases
Where next
- The spec Version 1, a draft: the syntax, the components, expressions, data and security.
- The guides The Author's Guide, for writing documents, and the Developer's Guide, for building CompMD into your app.
- The playground Write CompMD and watch the page, with a tour, examples and the diagnostics.
- The conformance set 209 cases any implementation can run, with their expected trees.
- GitHub The spec, the processor in Rust, the packages and this site.
- The npm packages The processor, the renderer, the sandbox, the registry client, the Vite plugin, the Developer Tools and the pack tools.
- The Rust crates The processor, compmd; what a host serves, compmd-host; and the Tauri plugin, tauri-plugin-compmd.