Draft. This is the editor's draft from main: it changes as the spec does, and a change can still break documents. The text on GitHub

CompMD Specification v1 (draft)

CompMD (Component Markdown) is a Markdown flavor that adds components, containers, slots and CSS-like layout to CommonMark + GFM, while staying readable in any plain Markdown renderer.

Version 1, draft. Last changed 2026-10-30. Every change is listed in the changelog.

Author: Hakan Serce.

Website: compmd.hakanserce.com, with this spec as a page and a playground to try CompMD in.

1. Status and conventions#Source

This is a working draft of CompMD version 1. It specifies document format only; parser and renderer implementations are out of scope.

1.1 Terms#Source

2. Documents#Source

A CompMD document is a CommonMark + GFM document, usually with a .comp.md extension and a frontmatter key md naming its version.

Property Value
File extension .comp.md
MIME type text/markdown; variant=compmd (RFC 7763 media type; compmd to be registered in IANA's Markdown Variants registry, RFC 7764, when version 1 is published)
Base syntax CommonMark 0.31, GFM as GitHub renders it, alerts and math (section 2.3)
Encoding UTF-8

What makes a file CompMD. A file is a CompMD document when its frontmatter has an md key, or when whoever opens it says so: the .comp.md extension or the media type above. A CompMD document without a version is version 1, permanently. A .md file without an md key is CommonMark, and CompMD processing never applies to it; processors MAY offer to read it as CompMD. Documents that are shared or renamed SHOULD keep the md key, so they stay CompMD on their own.

2.1 Frontmatter#Source

Frontmatter is a YAML block at the start of the document, delimited by --- lines. Everything CompMD reads is under one key, md; every other key belongs to the author.

---
md:
  version: 1
  import:
    - from: "@myorg/ui@1"
      names: [card, callout]
    - from: ./chart.jsx
      names: [chart]
  lang: en
title: Release notes
version: "1.4"
---

The author's version is md.doc.version; CompMD's is md.version inside the md key, and the two never mix.

Key under md Value
version 1, or a string with a minor version ("1.1"); defaults to 1
import component imports (section 6.1)
export component exports (section 6.2)
data data imports (section 8.6)
inputs values the reader can change (section 8.7)
lang the document's language, a BCP 47 tag; defaults to en (md.lang)
dir the document's text direction, ltr, rtl or auto; defaults to the direction of lang's script (rtl for ar, he, fa, ur, …, from CLDR)
tz the time zone of md.today(), an IANA name such as Europe/Berlin (md.tz)
syntax opt-in syntax the document uses, such as [wiki-links] (section 2.3)

YAML. Frontmatter is YAML 1.2 with the core schema: no is a string and only true and false are booleans, and 2026-10-21 is a string. Tags (!!…), anchors and aliases (&a, *a) and duplicate keys are not allowed (F033). Processors MAY cap frontmatter size (F033). A leading byte order mark is ignored. Only --- YAML is frontmatter; TOML (+++) is ordinary text. Write minor versions and other version-like values as strings: 1.10 is the number 1.1.

Metadata from a host. A processor MAY receive a document's metadata apart from its text, for example from a host that keeps it in its own fields. Such metadata is equivalent to the same keys in frontmatter. A document whose text has its own frontmatter uses only that; there is no merging, and a processor that was also given metadata reports W115.

2.2 Versions#Source

2.3 Base syntax#Source

CompMD's base syntax is what GitHub renders, plus math. Each part is common in real documents and reads well in plain renderers.

Syntax In compmd/1 Tree
CommonMark 0.31 always mdast
GFM: tables, task lists, strikethrough, autolinks, footnotes ([^1]) always mdast
Alerts: > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION] always a blockquote with alert set to the type in lower case ("note"), without the [!NOTE] line
Math: $…$ inline, $$…$$ and ```math fences for display always mdast inlineMath and math
Wiki links: [[Page]], [[Page|label]] opt-in, md.syntax: [wiki-links] wikiLink with target and label

3. Syntax#Source

CompMD adds five block-level line forms and one inline form. The number of colons tells the form, and every form is recognizable from its own line, without lookahead.

Form Marker Example Ends at
Container open ::: ::: card {title="Setup"} its matching closer
Param continuation ::: + ::: + icon=rocket end of line
Container close ::: / ::: /card end of line
Leaf :: :: chart[Weekly installs] {src=installs.csv} end of line
Slot :: # :: #footer end of line
Inline : :badge[beta]{tone=info} the closing ] or }

3.1 Grammar#Source

component-line        = indent ( container-open / param-cont / container-close / leaf / slot ) *WSP EOL
indent          = 0*3SP                          ; counted from the content column (section 3.2)
container-open  = ":::" 1*WSP name [ 1*WSP param-list ]
param-cont      = ":::" 1*WSP "+" 1*WSP param-body
container-close = ":::" 1*WSP "/" name
leaf            = "::" 1*WSP name [ fallback ] [ *WSP param-list ]
slot            = "::" 1*WSP "#" name
inline          = ":" name fallback [ param-list ]          ; no space before "{"

fallback        = "[" inline-markdown "]"                  ; ends at its matching "]" (section 3.2)
param-list      = "{" *WSP [ param-body ] *WSP "}"
param-body      = param *( 1*WSP param )
param           = key "=" value / "." name / "#" name / key
key             = [ name ":" ] name                         ; breakpoint prefix, section 9.3
name            = ALPHA *( ALPHA / DIGIT / "-" / "_" )
value           = quoted / expr / json-array / json-object / bare
quoted          = DQUOTE *( string-char / escape / interpolation ) DQUOTE
escape          = "\" ASCII-punctuation
expr            = "(" cel-expression ")"                    ; CompMD CEL profile, section 8.2
bare            = 1*bare-char
bare-char       = any Unicode character except white space, DQUOTE, "(", ")", "[", "]", "{" and "}"

Values. The first character of a value decides its kind, and each kind finds its own end:

First character Kind Ends at
" string the next " not escaped
( expression the matching ), counting parentheses outside CEL string and bytes literals ("…", '…', triple-quoted, r"…", b"…")
[, { JSON array, object the matching ] or }, counting outside JSON strings; RFC 8259, on one line
anything else bare the next white space or }

3.2 Recognition rules#Source

  1. Component lines are recognized wherever a CommonMark fenced code block could start: at document level, inside block quotes, list items and CompMD containers. Indentation is counted from the content column of the enclosing list item or block quote; at 4 or more spaces past it, a line is indented code. Component lines interrupt paragraphs, so they are never lazy continuation lines.
  2. A container opener or leaf directly after a paragraph line, without a blank line, is W108: plain renderers read it as part of the paragraph, which can change the structure around it (for example, join two lists that CompMD reads as separate). Closers, slot lines and continuation lines don't start new content, so they may follow a paragraph line directly.
  3. Component lines and inline components are never recognized inside code spans, fenced or indented code blocks, HTML blocks, or link destinations. An HTML block runs to the next blank line, so in <div> followed directly by ::: card, the ::: card line is part of the HTML; a line inside an HTML block that starts with :: is W116. A blank line after <div> ends the HTML block, and the card parses.
  4. An inline component is recognized only when its : is at the start of a line or preceded by white space or by punctuation other than : and \. So 10:30[x] and std::vec[0] are plain text.
  5. An inline component's param list follows its ] directly. :badge[beta] {tone=info} is a component without params followed by the text {tone=info}, and W107 reports it.
  6. In a GFM table, a | inside an inline component or a wiki link is written \|, as GFM requires inside code spans, because GFM splits rows into cells first: :badge[x]{show=(a \|\| b)}, [[Home\|start here]]. Unescaped, the row gets an extra cell, which GFM drops when the table has fewer columns.
  7. A line that begins (after its indentation) with :: but matches no CompMD form, such as :::card or :: /card, is a malformed component line. Processors MUST report F016 for it rather than render it silently as text.
  8. A backslash escapes CompMD syntax: \::: card and \:badge[x] are literal text, and the backslash is not rendered by CompMD processors.
  9. A fallback ends at the ] that matches its [: brackets nest, and \[ and \] don't count. As in CommonMark's link text, a code span binds tighter than the brackets, so a ] inside one doesn't end the fallback: :a[b c] d] has the fallback b `c]` d, with the code span c], as plain renderers show it. A run of backticks that no run of the same length closes is text, and its brackets count.

3.3 Canonical form (informative)#Source

A formatter keeps a document's meaning, so it changes only whitespace and blank lines:

It never changes quoting (a quoted value is always a string, a bare one is typed by the signature, so count="3" and count=3 can differ), param order, how params are split across continuation lines, or the text.

4. Containers, leaves, inline components and slots#Source

4.1 Containers#Source

A container opens with ::: name and MUST close with ::: /name, where both names are identical. Its body is ordinary CompMD: paragraphs, lists, tables, code, leaves and further containers.

- Install

  ::: card {title="Setup"}
  Run `rayplay init`.
  ::: /card

> ::: callout {kind=tip}
> A callout in a quote.
> ::: /callout

4.2 Slots#Source

A slot line :: #name inside a container starts the named slot; content runs until the next slot line or the container's closer.

4.3 Leaves#Source

A leaf :: name[fallback] {params} is a block-level component with no body. It occupies one line, plus continuation lines (section 5.2).

4.4 Inline components#Source

An inline component :name[fallback]{params} appears within paragraph text, headings, list items and table cells.

4.5 Fallback rendering#Source

When a CompMD processor cannot render a component (unsupported definition language, runtime error, missing import in lenient mode), it MUST render its fallback and report the matching diagnostic:

5. Parameters#Source

Parameters are key=value pairs, checked against the component's signature. They never span lines except through continuation lines, so a broken quote or bracket damages only its own line.

5.1 Forms#Source

Written Means
title="Setup" string Setup
icon=rocket bare value; typed by the signature
count=3 number 3 if the signature says number, else string 3
open boolean true
tags=["a","b"] JSON array
meta={"id":1} JSON object
.wide adds class wide (repeatable)
#intro sets id intro
md:gap=l gap override from breakpoint md up (section 9.3)

name=(expr) passes the result of an expression (section 8.2), typed by the expression. {{path}} is allowed in param values only inside quoted strings.

5.2 Continuation lines#Source

A ::: + params line adds params to the immediately preceding container opener, leaf, or continuation line. It takes the same param syntax without braces.

::: grid {gap=l align-items=start}
::: + md:grid-template-columns="2fr 1fr"
::: + lg:gap=2.5rem

A continuation line anywhere else is error F012.

5.3 Validation#Source

A processor MUST validate every invocation against the signature: unknown keys (F008), type mismatches (F009) and missing required params (F010). Each diagnostic points at the param, not the whole line.

6. Imports and exports#Source

Every .comp.md document can import components and export its own; a pack is simply a document (or published package) that exports components. Both are declared under the md frontmatter key, as import and export.

---
md:
  version: 1
  import:
    - from: "@myorg/ui@1"
      names: [card, callout]
    - from: "@other/kit"
      names:
        - name: card
          as: promo
    - from: ./chart.jsx
      names: [chart]
    - from: "@acme/icons"
      names: "*"
  export:
    pack: "@myorg/release-kit"
    version: "1.2.0"
    names: [callout, { name: chart, as: trend-chart }]
---

6.1 Imports#Source

Key Type Rule
md.import list of entries optional; @compmd/core is always imported and never listed
import[].from string required; @scope/pack, @scope/pack@<version> with a semver version or range (@1, @^1.4.2, @1.4.2), or a relative path (.comp.md, .js, .ts, .jsx, .tsx, .svelte)
import[].integrity string optional; a Subresource Integrity hash of the pack (sha512-…); requires an exact version (F009 otherwise)
import[].names list (at least one entry) or "*" required
names[] as a string name imports that export under the same local name
names[].name name the export's name in the source
names[].as name the local name; defaults to name
import:
  - from: "@acme/charts@1.4.2"
    integrity: sha512-9c1fQe…
    names: [chart]

6.2 Exports and pack identity#Source

Key Type Rule
md.export mapping optional; a document without it exports nothing
export.pack string @scope/name optional; the pack's published name. Required only to publish to a registry
export.version semver string optional; the pack's version
export.names list or "*" required; strings or {name, as} mappings, where as is the public name

6.3 Resolution#Source

A name in the body resolves through these sources, highest priority first:

  1. Local definitions (component= fences in this document).
  2. Explicitly listed imports.
  3. Wildcard ("*") imports and @compmd/core, at the same level.
  4. Ambient packs a processor provides.

Rules:

Ambient packs. A processor MAY provide packs that every document imports implicitly, like @compmd/core, at the lowest priority, so local names and imports always win. Names from them are not portable: another processor reports F002 and renders fallbacks. A processor SHOULD also let documents import an ambient pack explicitly (from: "@host/components"), so a document can state what it depends on, and MAY report W113 for each ambient name used.

6.4 Packages and the registry#Source

7. Defining components#Source

A component is defined in a fenced code block whose info string is the source language followed by component=<name>. Plain renderers show it as highlighted code; CompMD processors remove it from the rendered output. The first word is always the source language, so plain renderers can highlight it; anything else goes in attributes after it.

Language runtime (default first) Defined in
compmd none, every processor runs it section 7.2
jsx, tsx react@19, or preact@10 the React binding (section 7.6)
svelte svelte@5 the Svelte binding (section 7.6)
js, ts none: plain JavaScript the plain binding (section 7.6)
````compmd component=snippet
params:
  code: string
---
```sh
{{code}}
```
````

7.1 Host-language definitions#Source

```svelte component=card runtime=svelte@5
<script>
  let { title, icon = "info", children, footer } = $props();
</script>
<section class="card"><h3>{title}</h3>{@render children?.()}<footer>{@render footer?.()}</footer></section>
```

Signatures. A host-language component's signature (params, types, defaults, slots) comes from a compmd signature fence, or is derived from the component:

```compmd component=card signature
params:
  title: string
  icon: info | rocket | warning = info
slots: [default, footer]
```

7.2 The CompMD definition language#Source

A compmd definition has a signature header, a --- line, and a body of CompMD. It needs no host framework, so it renders in every CompMD processor.

```compmd component=card {display=flex flex-direction=column gap=s padding=m border=subtle border-radius=m class=(tone)}
params:
  title: string
  icon: string = "info"
  tone: info | warn = info
slots: [default, footer]
---
# {{title}}
{{md.slot}}

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

A block invocation of a compmd component is one box, styled by the fence's params, holding the body (section 9.2).

An inline invocation of a compmd component (:badge[beta]{tone=info}) puts the children of its body's paragraph in the text; a body that isn't a single paragraph renders the fallback. It has no box, so the fence's params don't apply.

Headings in a body are relative by default: # is one level below the heading the invocation is under, ## two levels below, and so on, up to level 6; with no heading above the invocation, # is level 1. So card's title is an h3 under ## What changed and an h2 under the document's # title, and the outline stays correct wherever card is used. Slot content isn't shifted: the caller wrote its levels. A definition whose headings must keep their written level (a title page) declares headings: absolute in its signature (section 7.3). The parse tree keeps the written levels; the expanded tree has the computed ones (section 12.3).

7.3 Signatures and type declarations#Source

A signature (the header of a compmd definition, or a signature fence) is YAML: params maps each param name to a declaration, and slots lists slot names. The same declarations are used for inputs (section 8.7) and, with from, for a data import's schema (section 8.6).

A declaration has a short form, a YAML string in this grammar:

type-decl = type [ "?" ] [ 1*WSP "=" 1*WSP default ]
type      = "string" / "number" / "boolean" / "date" / "array" / "object"
          / "length" / "space" / "color" / "tracks" / "areas" / "surface" / "border" / "radius"
          / "name" / enum / input
enum      = name 1*( *WSP "|" *WSP name )
input     = "input<" ( type / "enum" ) ">"          ; a bound input (section 7.4)
default   = value                                 ; the param value grammar, section 3.1

and a long form, a YAML mapping for what the short form can't say well:

params:
  tone: info | warn = info              # short form
  icon: string?
  meta:                                 # long form
    type: object
    default: {a: 1}
    doc: Extra data passed to the chart
Key Rule
type required; a type from the grammar above, with an optional ?
default optional; any YAML value of that type
doc optional; a description for editors and tools
from data schema only; the key in the data, when it differs from the declared name

7.4 The host contract#Source

A processor that renders HTML runs a host-language component through one contract, whatever its framework. Processors for other outputs render its fallback (W102).

mount(target: Element, context: {
  params: Record<string, unknown>,        // validated against the signature, typed
  slots: Record<string, SlotItem[] | SlotInstance[]>, // each slot's content, by slot name ("default" too)
  set(param: string, value: unknown): void, // changes the input bound to an `input` param
  fallback: { text: string; element: Element }, // the invocation's fallback, as plain text and rendered
  headingLevel: number,                   // the level the component's top heading should have (section 7.2)
  host?: unknown,                         // what the processor gives components it trusts (below); absent otherwise
}): { update?(params, slots): void; destroy?(): void }

type SlotItem = { element: Element; component?: { name: string; params: Record<string, unknown> } }
type SlotInstance = { value: unknown; items: SlotItem[] }   // a template slot: one per element (section 7.5)
:: chart[Sales by region] {rows=(md.data.sales) selected=region}
:: table[Sales] {rows=(md.data.sales.filter(s, md.input.region == "" || s.region == md.input.region))}

Clicking a bar calls set("selected", "eu"); the table renders again with the rows for eu.

7.5 Template slots#Source

A template slot is rendered once per element of an array param, with a name the author picks for the element. It lets a component decide where each record's template goes: a board that groups cards by status, a calendar that puts each event in its day.

# the board's signature
params:
  rows: array
  by: string
  as: name
slots:
  default: {each: rows, as: as}
::: board {rows=(md.data.tasks) by=status as=t}
**{{t.title}}**, due {{t.due}}
::: /board

To repeat a component once per record in order, no template slot is needed: put the component inside each (section 8.3).

7.6 Bindings#Source

A binding maps the host contract to one framework.

Binding Language, runtime Default slot Named slots Items, instances, set, fallback, headingLevel, host
React jsx, tsx; react@19, preact@10 children, a node that places the slot's elements a prop of the same name, the same kind of node a compmd prop: {slots, set, fallback, headingLevel, host}
Svelte svelte; svelte@5 the children snippet a snippet prop of the same name a compmd prop: {slots, set, fallback, headingLevel, host}
Plain js, ts; none slots.default slots.<name> the context, as in section 7.4

In the React and Svelte bindings, params are props of the same name (a param with - in its name becomes camelCase: align-items is alignItems), and the binding implements mount, update and destroy with the framework's own mounting. In the plain binding the default export is mount, with the context of section 7.4, and params keep their names:

export default function mount(target, { params, fallback }) {
  const chart = drawChart(target, params.rows, fallback.text);
  return { update: (next) => chart.update(next.rows), destroy: () => chart.remove() };
}

8. Interpolation, expressions and control flow#Source

8.1 Interpolation#Source

{{path}} inserts a value. It works in document bodies and in compmd definition bodies, and values come only from the names in this table.

Path Available in Value
md.doc.<key> everywhere an author's frontmatter key (section 2.1)
md.version, md.lang, md.tz everywhere the document's version, language and time zone (section 2.1)
md.data.<name> everywhere a data import (section 8.6)
md.input.<name> document bodies an input's current value (section 8.7)
<param> compmd definition bodies the invocation's param value or its default
<as name> inside each and template slots the current item (sections 7.5, 8.3)
md.loop.<field> inside each iteration info (section 8.3)
md.slot compmd definition bodies the default slot's rendered content
md.slot.<name> compmd definition bodies that named slot's rendered content, or empty

Everything CompMD provides, variables and functions, is under the md. namespace; other names are chosen by authors (params, as names and inputs). Authors cannot declare the name md (F013).

Form. {{, optional spaces, a path, optional spaces, }}, on one line. A path is names joined by ., each a CEL identifier ([A-Za-z_][A-Za-z0-9_]*); v1 has no expressions, operators, indexes or filters. A param named with - is written with _ (section 5.1); data keys that aren't identifiers are renamed in the data import's schema (section 8.6).

Where.

What it inserts.

Errors.

A release.comp.md file, CompMD by its extension, with only the author's frontmatter:

---
version: "1.4"
---

Install RayPlay {{md.doc.version}} with `cargo install rayplay`.

8.2 Expressions#Source

A param value written in parentheses, name=(expr), is an expression in the CompMD CEL profile, a defined subset of the Common Expression Language. Computation happens only in expressions on component lines, never in prose.

:: badge[{{status}}] {tone=(status == "done" ? "success" : "neutral")}
::: box {class=("callout-" + kind)}

Grammar. The value rule in section 3.1 gains expr = "(" cel-expression ")". The expression ends at the matching ), counting parentheses outside CEL string literals, and MUST fit on one line.

Variables. An expression sees the component's params (inside a compmd definition body), md.doc (the author's frontmatter of the document the expression is written in), md.version, md.lang and md.tz (section 2.1), md.data (section 8.6), md.input (section 8.7), and inside each the as name and md.loop. Nothing else exists: there are no environment, device or network variables. md.doc, md.data, md.input and md.loop are qualified CEL identifiers, declared to the CEL environment as variables next to the md. functions.

Types. CompMD types map to CEL types as follows; expressions are type-checked against the declared param type.

CompMD type CEL type Notes
string, enum string enum values are checked against the enum's members
number double also when the value has no fraction (3)
boolean bool
date string an ISO 8601 calendar date, "2026-10-21"
array list
object map(string, dyn)
length, space, color, tracks, areas, surface, border, radius string only ==, != and string concatenation are allowed; grammars in section 9.4

Numbers. Every number from frontmatter, data, params and inputs is a CEL double. Integer literals, size(), md.loop fields, md.daysBetween and list indexes are int, as in CEL. The profile adds arithmetic and comparison between int and double, which gives a double: p.price * 12 and md.input.rate * 100 > 5 need no conversion. Everything else follows CEL: dividing two ints truncates (size(rows) / 2 with 3 rows is 1; write double(size(rows)) / 2), and a list index must be an int (rows[int(md.input.page)]). string() writes a double as interpolation does (section 8.1): string(1e21) is "1e+21". Map keys of different number types match by value, as in CEL: {1: "a"}[1u] and {1: "a"}[1.0] are "a", so md.groupBy on a field of whole numbers from data gives int keys.

Dates and times. A date is a string holding an ISO 8601 calendar date ("2026-10-21"), so dates from frontmatter, data and inputs need no conversion, and they compare correctly with <, >= and ==. Times are CEL timestamps and durations, for the few documents that need a time of day. Data holding full timestamps ("2026-10-21T10:00:00Z") compares with a date as strings, which works for ranges in one zone; for exact times, convert with timestamp(s.at).

Profile. The profile is exactly:

It excludes protocol buffer messages, matches (regular expression dialects differ between implementations, and some can be made slow), and every other extension.

Function Example Result
md.currency(n, code) md.currency(price * 10, "USD") "$120.00"
md.number(n, digits) md.number(0.183 * 100, 0) "18"
md.date(d, style) md.date(md.doc.released, "long") "October 21, 2026"
md.plural(n, one, other) md.plural(count, "item", "items") "items"
md.plural(n, forms) md.plural(n, {"one": "файл", "few": "файла", "many": "файлов", "other": "файла"}) the form for n
md.today() md.today() the render date, a date
md.now() md.now() the render time, a timestamp
md.dateOf(t) md.daysBetween(md.dateOf(md.doc.created), md.today()) the date of a timestamp in the render's time zone (md.tz)
md.addDays(d, n) md.addDays(md.today(), -7) a date
md.daysBetween(a, b) md.daysBetween(md.today(), md.doc.launch) an int, negative when b is before a
md.startOf(d, unit) md.startOf(md.today(), "week") a date; unit is "week", "month" or "year"
List function Example Result
md.sort(list) md.sort(md.doc.tags) ascending
md.sortBy(list, field) md.sortBy(md.data.sales, "amount") ascending by that field
md.reverse(list) md.reverse(md.sortBy(md.data.sales, "amount")) reversed
md.slice(list, start, end) md.slice(rows, 0, 5) the elements from start up to end
md.sum(list) md.sum(md.data.sales.map(s, s.amount)) a number; 0 for an empty list
md.min(list), md.max(list) md.max(md.data.sales.map(s, s.amount)) a number, or null for an empty list
md.unique(list) md.unique(md.data.sales.map(s, s.region)) first occurrences, in order
md.groupBy(list, field) md.groupBy(md.data.sales, "region") a map from each value to its elements, in order
md.list(list), md.list(list, "or") md.list(md.doc.contributors) "Ana Lima, Kenji Sato, and Priya Nair", joined by the locale's list pattern ("or": alternatives)

Locale. The formatting functions (md.currency, md.number, md.date, md.plural, md.list) and the first day of the week in md.startOf follow md.lang (section 2.1), which defaults to en, with the locale data of a recent CLDR release. The spec doesn't pin a release, so processors can use their platform's data; output in other languages may differ slightly between processors. md.date takes CLDR's styles "short", "medium", "long" and "full". md.plural with a map picks CLDR's plural category for n (zero, one, two, few, many, other), falling back to other, which is required; the three-argument form is a shorthand for {"one": one, "other": other}.

Checking. Expressions in compmd definitions MUST be type-checked when the definition is loaded; expressions in document bodies are checked against the frontmatter when rendered. Any parse, type or evaluation error is F024, reported with the column inside the expression and, for unknown variables, the nearest declared name.

8.3 Control flow#Source

Control flow is two ordinary @compmd/core containers, if and each. They use the same syntax, named closers and diagnostics as every other component; there are no keywords.

if#Source

Param Type Meaning
when boolean required; the default slot renders when true

Slot else renders when when is false.

::: if {when=(kind == "warning")}
**Read before upgrading.**
:: #else
This release is backward compatible.
::: /if

Prefer an expression in a param over an if when only a value differs: tone=(done ? "success" : "neutral") instead of two branches.

each#Source

Param Type Meaning
in array required; the items
as name required; the item's variable name, a CEL identifier (no -)
join none | list | inline = none how iterations combine
sep string = " " separator for join=inline
last string? separator before the last item for join=inline; defaults to sep
Slot Renders
default once per item, with the as variable and md.loop bound
before once, above the result, only if there is at least one item
empty once, only if there are no items

md.loop has index (0-based, matching CEL list positions), number (1-based, for display), count, first and last. The as variable and md.loop are visible in the default slot only; nested each blocks see their own md.loop, and outer items by their as names.

Join modes. CompMD parses before it expands, so each iteration's default slot is already Markdown blocks; join says how they combine.

join Each iteration's default slot must be Result
none anything blocks appended in order
list list items of one list type one list. Ordered lists number continuously. Tight unless any item contains blank lines
inline exactly one paragraph one paragraph, iterations separated by sep, the last pair by last

An iteration whose shape does not match is F025, reported at the opener and naming the first offending line. A default slot that is a single list item with join unset is W103.

::: each {in=(md.doc.changes.filter(c, c.type == "breaking")) as=c join=list}
- {{c.text}} ([#{{c.pr}}](https://github.com/acme/rayplay/pull/{{c.pr}}))

:: #before
## Breaking changes
::: /each

::: each {in=(md.doc.stack) as=s join=inline sep=", " last=" and "}
[{{s.name}}]({{s.url}})
::: /each

The first block renders a heading and one list, or nothing when there are no breaking changes. The :: #before line is at the container's own level, not indented under the list item, which would put it inside the item. The second block renders one paragraph, such as "Rust, wgpu and WebAssembly".

Repeating a component. each around a component repeats it once per record, in order; the component knows nothing about the iteration:

::: each {in=(md.data.team) as=p}
::: card {title="{{p.name}}"}
{{p.role}}, since {{p.since}}
::: /card
::: /each

When a component must decide where each record's template goes (grouping, a calendar), it declares a template slot instead (section 7.5). each's own signature is one: in: array, as: name, slots: {default: {each: in, as: as}, before: {}, empty: {}}; its join modes are its own.

Tables. A GFM table is one block, so each cannot produce table rows. Data tables come from a component (for example a pack's :: table {rows=(md.doc.plans) cols=(["name", "price"])}).

8.4 Computed text#Source

{{ }} takes paths only. Computed or formatted text in prose uses the @compmd/core inline component value:

Param Type Meaning
of string required; rendered as text. Numbers and dates go through string() or a formatting function: of=(string(size(rows)))
The annual plan is :value[$120/year]{of=(md.currency(md.doc.price * 10, "USD") + "/year")},
saving :value[$24]{of=(md.currency(md.doc.price * 2, "USD"))}.

# :value[Release notes]{of=(md.doc.strings[md.lang].title)}

The fallback is what plain renderers and failing processors show, so authors SHOULD write the value it normally produces.

8.5 Evaluation#Source

8.6 Data imports#Source

The data key under md loads data from files or URLs into md.data, for use in expressions and interpolation.

---
md:
  version: 1
  data:
    - from: ./team.yaml
      as: team
    - from: ./plans.csv
      as: plans
      schema: {name: string, price: number, seats: number}
    - from: "@myorg/ui/icons.json"
      as: icons
    - from: ./site.comp.md
      as: site
    - from: https://api.example.com/status.json
      as: status
      cache: 1h
      fallback: ./status.json
---

::: each {in=(md.data.team.filter(p, p.active)) as=p join=list}
- **{{p.name}}**, {{p.role}}
::: /each
Key Type Rule
from string required; a relative path, a file inside an imported pack (@scope/pack/file), an https:// URL, or a host: source (below); MAY interpolate inputs (below)
as name required; the name the loaded value gets in the document: as: team makes it md.data.team
schema declarations (section 7.3) optional; for a list of records, the type of each field, and from to rename a key
cache duration (30s, 10m, 1h, 1d) optional; remote only; how long a fetched copy may be reused
fallback local path optional; remote and host: only; loaded when the source fails
delimiter one character optional; CSV only; the field separator, default , (a tab for .tsv)

Formats are chosen by extension, or by Content-Type for URLs:

CSV follows RFC 4180: fields separated by the delimiter, " quotes a field and "" writes a quote, quoted fields may hold separators and line breaks, lines end in LF or CRLF, the text is UTF-8 (a byte order mark is ignored). The first row is the header and is required.

Typing. With schema, expressions over the data are type-checked like param expressions, and each record is validated when loaded (F027). Without it, the data is dyn and type errors surface at evaluation (F024); for data without a schema, the F024 message SHOULD suggest adding one.

Renaming. A schema entry's from names the key in the data, so keys that aren't identifiers (spreadsheet headers with spaces or -) get names paths can use:

md:
  data:
    - from: ./sales.csv
      as: sales
      schema:
        product: {from: "Product", type: string}
        unit_price: {from: "Unit price", type: number}
        units_sold: {from: "units-sold", type: number}
::: each {in=(md.data.sales) as=s join=list}
- {{s.product}}: {{s.units_sold}} sold at {{s.unit_price}}
::: /each

Scope is lexical: data imported by a pack is visible to that pack's definitions only, never to documents importing the pack.

Relative paths resolve against the file they are written in, as url() does in CSS. This applies to data from and fallback values, imports (section 6.1), and link and image destinations in compmd definition bodies: ![](./icons/star.svg) in a pack's card is the pack's file wherever card is used. Slot content is written by the caller, so its paths resolve against the caller's document. A relative path cannot leave its root, the pack's folder or the document's project folder (which the processor decides): ../ beyond it is F026. Making a pack's files reachable in the output is the processor's job.

Values in data are never paths: a frontmatter value logo: ./logo.png, imported from another folder and used as ![]({{md.data.site.logo}}), resolves where it is used. Data meant to be used elsewhere SHOULD hold absolute or root-relative URLs.

Host sources. A from that starts with host: names data the processor provides, such as a host application's own records; the processor defines what follows the colon. It is the one extension point for data from hosts:

md:
  data:
    - from: host:tasks?status=open&project={{md.input.project}}
      as: tasks
      schema: {title: string, due: date, project: string}

Values in from. A from string MAY interpolate inputs and frontmatter, so the reader's choices pick what is loaded:

md:
  data:
    - from: https://api.example.com/sales?from={{md.input.from}}&region={{md.input.region}}
      as: sales
    - from: ./sales-{{md.input.year}}.csv
      as: year

Remote data.

Errors. A missing, unreachable or unparseable source is F026, reported at the line and column inside the data file when available. A record that violates schema is F027, naming the record and field.

8.7 Inputs#Source

An input is a typed value the reader can change, such as the start date of a dashboard. Inputs are declared in the inputs key under md, read as md.input.<name>, and changed through input components.

---
md:
  version: 1
  import:
    - from: "@myorg/charts@1"
      names: [chart]
  inputs:
    from: date = (md.today())
    region: all | eu | us = all
  data:
    - from: ./sales.csv
      as: sales
      schema: {date: date, region: string, amount: number}
---

:: date-input[From: today] {bind=from}
:: select-input[Region: all] {bind=region}

:: chart[Sales by day] {rows=(md.data.sales.filter(s, s.date >= md.input.from && (md.input.region == "all" || s.region == md.input.region)))}

Declaring. Each entry under inputs names an input, a CEL identifier, and gives its type and default as a declaration (section 7.3). Every input MUST have a default, so a document always renders. Inputs belong to the document being rendered: md.input is available in document bodies and in from values (section 8.6), not in compmd definition bodies, which receive input values as params. An imported document's inputs are not used.

Values. An input's value comes from whoever runs the processor (a command-line argument, a URL query, a host) when given, otherwise from its default. A given value that doesn't match the input's type is rejected, and the default is used. Where values are kept between renders is the processor's business.

Input components. @compmd/core provides leaves, implemented by the processor (section 9), that show an input's value and let the reader change it:

Component Edits inputs of type
date-input date
number-input number
text-input string
select-input an enum; its members are the choices (input<enum>)
toggle-input boolean

One direction. Values flow from inputs to expressions to output. There are no events, scripts or other state; compmd definitions stay free of code and need no sandbox (section 15). Host-language components change an input only through a param of type input<…> that the document binds to it (section 7.4).

9. Built-ins: the @compmd/core pack#Source

Built-ins are an ordinary pack, @compmd/core, imported into every document. It declares one primitive the processor implements, box, and builds stack, row and grid on it in the CompMD language.

Component Defined as Lays out children Own params
box processor per display display (block, flex, grid; contents on a definition fence, section 9.2), flex-direction, gap, align-items, justify-content, flex-wrap, grid-template-columns, grid-template-rows, grid-template-areas
stack CompMD, on box top to bottom gap, align-items
row CompMD, on box side by side, wrapping when there isn't room gap, align-items, justify-content, flex-wrap (wrap by default, or nowrap)
grid CompMD, on box as cards, as many per row as fit (min), or on an exact grid min, gap, align-items, grid-template-columns, grid-template-rows, grid-template-areas

@compmd/core also exports if and each (section 8.3), value (section 8.4) and the input components (section 8.7), which the processor implements too.

Components the processor implements. In a pack a processor provides (@compmd/core, an ambient pack, section 6.3), a signature fence with no definition and no import means the processor implements that component: the pack states its signature, so documents get validation and fallbacks for it like for any component. In a document, a signature fence with nothing to attach to is F002. A processor targets a new output (PDF, terminal) by implementing box alone.

---
md:
  version: 1
  export:
    pack: "@compmd/core"
    version: "1.0.0"
    names: "*"
---

```compmd component=box signature
params:
  display: block | flex | grid | contents = block
  flex-direction: row | column = row
  gap: space = none
  align-items: start | center | end | stretch = stretch
  justify-content: start | center | end | between | around | space-between | space-around = start
  flex-wrap: wrap | nowrap = nowrap
  grid-template-columns: tracks?
  grid-template-rows: tracks?
  grid-template-areas: areas?
slots: [default]
```

```compmd component=row {display=flex flex-direction=row gap=(gap) align-items=(align_items) justify-content=(justify_content) flex-wrap=(flex_wrap)}
params:
  gap: space = none
  align-items: start | center | end | stretch = stretch
  justify-content: start | center | end | between | around | space-between | space-around = start
  flex-wrap: wrap | nowrap = wrap
slots: [default]
---
{{md.slot}}
```

stack and grid follow the same pattern: the fence styles the component's box, and the body is the slot. Because @compmd/core is written in CompMD, third-party packs can define their own layouts the same way.

Positioned layout. Any block can be placed at a position in its box with the inset-* common params (section 9.2), like a note pinned on a board:

::: box {height=12rem padding=m}
This week's notes, pinned where they matter.

::: box {inset-inline-start=60% inset-block-start=3rem width=30%}
Skipped Tuesday: travel.
::: /box

:: badge[3 new] {inset-inline-end=8px inset-block-start=8px}
::: /box

The paragraph flows as usual; the note and the badge are placed over it.

9.1 Items#Source

Each top-level block in a layout container's default slot is one item: a paragraph, list, table, heading, leaf, or nested container. To make several blocks one item, wrap them in ::: box. A compmd component is one item, its box, unless its definition is display=contents; then its blocks are the container's items (section 9.2).

9.2 Common params#Source

Any container or leaf (built-in or not) accepts these params, applied to its outer box:

Param Values Example
width, height, min-width, max-width a length, a percentage or auto width=40%
padding one or two spaces (none, s, m, l or a length): block, then inline padding=m, padding="s l"
padding-inline, padding-block one or two spaces (none, s, m, l or a length): start, then end padding-inline=l
padding-inline-start, padding-inline-end, padding-block-start, padding-block-end a space (none, s, m, l or a length) padding-block-start=s
border a token (none, subtle, strong) or a CSS border border=subtle, border="1px solid"
border-radius a token (none, s, m, l, full) or a length border-radius=m
background a token (none, subtle, strong, accent) or a CSS color background=subtle
grid-area an area name of the parent's grid-template-areas grid-area=side
order first, last, none order=first
align-self start, center, end, stretch align-self=center
justify-self start, center, end justify-self=end
aspect-ratio width over height, or none aspect-ratio=16/9
inset-inline-start, inset-inline-end, inset-block-start, inset-block-end a length, a percentage or auto (the default) inset-inline-start=16px
scroll a flag, or true or false scroll
fill a flag, or true or false fill
sticky top, bottom, none sticky=top
show, hide sm, md, lg show=md

9.3 Breakpoints#Source

A layout or common param MAY carry a breakpoint prefix: md:gap=l overrides gap from the md breakpoint up. The breakpoints are fixed: sm is 480 px, md 768 px and lg 1024 px. Documents start from the narrowest layout and override upwards.

9.4 Values and spacing#Source

9.5 Which box breakpoints follow (informative)#Source

Each example says which box a block's breakpoints follow (section 9.3), on a page 1000 px wide.

10. Errors, diagnostics and recovery#Source

Every diagnostic MUST name its code, the line and column where it was detected, the construct involved, and any related location (such as the opener of an unclosed container). One mistake MUST produce one primary diagnostic, not a cascade.

10.1 Modes#Source

10.2 Catalog#Source

Code Condition Reported at
F001 Unsupported major version (md.version) line 1
F002 Unknown component name (diagnostic SHOULD suggest the nearest declared name) the name
F003 Closer names no open container while one is open (a misspelt name) the closer, naming the innermost open container and its line
F004 Container not closed before an enclosing closer or end of file the opener, and where it was auto-closed
F005 Closer while no container is open the closer
F006 Undeclared slot name the slot line
F007 Named slot given twice in one container the second slot line, naming the first
F008 Unknown param, or a breakpoint the param doesn't take the param
F009 Param value does not match its type or CSS grammar the value
F010 Required param missing the opener or leaf
F011 Malformed param list (unterminated string, unbalanced bracket, invalid JSON, a character a bare value can't hold, an empty value) the ", (, [ or { that doesn't close, the character a bare value can't hold, or the = with no value
F012 Continuation line not following an opener, leaf, or continuation the line
F013 Duplicate local name from definitions or explicit imports, or a keyword (if, each, value, box) defined, imported, exported or used as as; the name md declared; a CEL reserved word or type name as a compmd definition's param, an as name or an input (section 3.1); two params of one component differing only in - and _ the second declaration
F014 Unknown interpolation path the path
F015 Slot line outside a container the line
F016 Malformed component line (starts with :: but matches no form) the line, with the expected form
F017 Param given twice, or id together with #name the second occurrence
F018 Body given to a component that declares no slots the opener
F019 Recursive component definition the invocation that closes the cycle
F020 Import cycle the from that closes the cycle
F021 Imported name not exported by its source the names entry
F022 export.pack or export.version disagrees with the registry manifest the export key
F023 Name provided by two wildcard imports, or by a wildcard import and @compmd/core, is used the use, naming both sources
F024 Expression parse, type or evaluation error, or a list or a map without a name interpolated into text the column inside the expression, or the interpolation
F025 each iteration does not match the shape its join mode requires the each opener, naming the first offending line
F026 Data or import source missing, unreachable, unparseable or not provided (host:), a relative path leaving its root, or a bad CSV header the data entry, and the position inside the data file when available
F027 Data record violates its schema the record and field
F028 Remote data origin not allowed by processor configuration the from value
F029 Exported host-language component has no signature fence the export.names entry
F030 A param of type input<…> (an input component's bind, a host component's bound param) names an undeclared input, or an input of another type the param's value
F031 A from interpolates something other than md.input or md.doc, an input a host-language component can change, or a value in a URL's scheme or host the interpolation
F032 Closer names a container opened outside its list item or block quote the closer, naming the opener's line
F033 Frontmatter isn't YAML 1.2 core, uses tags, anchors or aliases, repeats a key, or exceeds the processor's size cap the frontmatter, at the offending position
F034 Expansion larger than the processor allows (at least 100,000 nodes, section 8.5) the each, template slot or invocation that crossed the limit
F035 Pack does not match its import's integrity hash the integrity key
F036 Param list on a signature fence; ignored the {
F037 A declaration's type isn't in the type grammar (section 7.3), in a signature, an input or a data schema; the declaration takes any value (diagnostic SHOULD suggest the nearest type name) the type name
W101 Leaf without fallback text (plain renderers show nothing, and a host component has no text alternative) the leaf
W102 Definition language not supported by this processor; fallback rendered the invocation
W103 each default slot is a single list item and join is unset the each opener
W104 Signature fence and component disagree (missing prop, type or slot) the signature entry, naming the component's declaration
W105 Local host-language component has params that could not be typed (dyn) the definition fence
W106 {{ followed by something that isn't a path; kept as text the {{
W107 Inline component followed by a space and a param list, which is read as text the {
W108 Container opener or leaf directly after a paragraph line, without a blank line the component line
W109 Interpolated link or image URL with a scheme other than https:, http: or mailto:; rendered without the link the URL
W110 Document declares a later minor version than the processor implements md.version
W111 Unknown key under md; ignored the key, naming the nearest known key
W112 Local definition or explicit import shadows a @compmd/core component the definition or names entry
W113 Name from an ambient pack, or a host: data source, used (optional; for portable documents) the use, or the data entry
W114 Box whose blocks use breakpoints, without a width where its width would come from its content (section 9.3) the box
W115 Host metadata given for a document that has its own frontmatter; the host's metadata is ignored line 1
W116 Line starting with :: inside an HTML block, which makes it HTML, not CompMD the line
W117 Exported host-language component without runtime the definition fence
W118 Host-language component threw while mounting or updating; fallback rendered the invocation
W119 A box that places blocks by position has no height, aspect-ratio or fill at some width (section 9.2) the box's opener; at the top level, the placed block
W120 A param for a box given to a display=contents component, which has none, on its fence or by an invocation; ignored (section 9.2) the param

10.3 Recovery rules (lenient mode)#Source

Recovery follows four principles:

  1. Keep the structure whenever a component's name is readable.
  2. Drop the smallest broken piece (a param, a line, a record), never author content.
  3. No cascades: a diagnostic caused by one already reported is not reported.
  4. Show content rather than hide it when unsure.
Code Recovery
F001, F033 No CompMD parsing: the file renders as CommonMark and GFM
F003 The closer closes the innermost open container
F004 A closer naming an outer open container closes every container inside it, one F004 each; at the end of the file, every open container is closed there
F005 The closer line is ignored
F032 The closer line is ignored; the container it names closes at the end of its own parent
F007 The second slot's content is appended to the first
F011 On a block line, the component keeps the params before the break and is marked recovered. In an inline component, the component keeps no params, and the rest of the line from { is text
F012 The continuation line is ignored
F015 The slot line is ignored; what follows is normal content
F016 The line is plain text
F017 The first occurrence wins
F002, F010, F019, F023, F030 The component renders its fallback (section 4.5)
F013 The first declaration wins
F020, F021, F035, F026 for an import The import, or the name, is dropped; its uses render fallbacks without F002
F022 The document's pack and version are used
F029 The export's signature is derived, as for a local component
F006 The undeclared slot's content joins the default slot
F008 The param is ignored
F009, F024 in a param The param is treated as absent: its default is used, or the component renders its fallback if the param is required
F014, F024 in an interpolation The interpolation is shown as written
F018 The component renders without a body, and the body follows it as normal content
F024 in an evaluation The component renders its fallback
F025 The iterations are appended, as with join=none
F026 for data, F028, F031 The data is its fallback file if given, otherwise null (an each over it renders its empty slot)
F027 The record is dropped
F034 What was expanded before the limit is kept; the component that crossed it renders its fallback (an each or template slot, its template once)

Warnings need no recovery: each describes what the processor does anyway.

10.4 Diagnostics in imported definitions#Source

10.5 Machine format#Source

Processors that report diagnostics to other tools use this JSON form:

{
  "code": "F024",
  "severity": "error",
  "message": "no '+' for string and int",
  "file": "ui.comp.md",
  "range": { "start": { "line": 7, "column": 24 }, "end": { "line": 7, "column": 32 } },
  "related": [
    { "file": "release.comp.md", "range": { "start": { "line": 12, "column": 1 } }, "message": "invoked here (card)" }
  ],
  "stage": "validate"
}

11. Processing model#Source

A processor works in stages. Each stage needs only what its row lists, and reports only the diagnostics listed for it.

Stage Needs Produces Diagnostics
1. Parse the document's text the document tree (section 12) F001, F003–F005, F007, F011, F012, F015–F017, F032, F033, F036, W101, W106–W108, W110, W111, W115, W116
2. Resolve the tree, imported documents and packs each name bound to a definition and signature F002, F013, F019–F023, F026 (imports), F029, F035, W104, W105, W112, W113, W117
3. Validate the resolved tree and signatures a checked tree, params typed F006, F008–F010, F014, F018, F024 (types), F028, F030, F031, W102, W103, W120
4a. Load the checked tree and the input values the loaded data F026, F027, W113 (host:)
4b. Evaluate the checked tree, the loaded data, the render context the expanded tree (section 12.3) F024 (evaluation), F025, F034, W109, W114, W119
5. Render the expanded tree output (HTML, PDF, …) W118

12. Document tree#Source

The tree is the result of stage 1 and the subject of conformance tests (section 13). CommonMark and GFM content uses mdast nodes, and the frontmatter is mdast's yaml node. CompMD adds the nodes below. Every node carries its source position (unist position), which diagnostics refer to.

12.1 CompMD nodes#Source

Node Fields
componentContainer name, params, children (the default slot), slots (one componentSlot per named slot, in source order), closePosition
componentSlot name, children
componentLeaf name, fallback (inline children), params
componentInline name, fallback (inline children), params
componentDefinition name, language, signature (null when the fence has none), params (the fence's param list, section 7); for compmd, children (the parsed body); for host languages, source (the text) and runtime (null when the fence has none)
componentSignature name, signature (a signature fence, section 7.1)
interpolation path, block

params, fallback, children and slots are always present, as empty lists when there is nothing. A leaf written without […] has an empty fallback.

A param is {key, breakpoint, kind, value, position}:

A signature is {params, slots, headings}: slots is the list of slots, or null when the signature has no slots key, each a slot name or, for a template slot, {name, each, as}; headings is "relative" or "absolute" (section 7.2); each param is {name, type, optional, default, doc}:

12.2 Rules#Source

12.3 The expanded tree#Source

Stage 4b replaces every compmd definition, if, each, value and interpolation with what it produces. The expanded tree is content only: definition fences, signature fences and the yaml node are left out (the parse tree keeps them). It holds mdast nodes, plus componentContainer, componentLeaf and componentInline nodes only for components the processor renders itself: box, the input components, and host-language components.

12.4 Serialization#Source

Trees are serialized as JSON with the field names above. The JSON schema, conformance/compmd-tree.schema.json (JSON Schema 2020-12), defines both trees: #/$defs/parseRoot for the parse tree (sections 12.1, 12.2) and #/$defs/expandedRoot for the expanded tree (section 12.3). It checks this spec's nodes and params strictly and other nodes as mdast nodes; positions are optional. Every expected tree in the conformance set fits it.

13. Conformance#Source

13.1 What conforms#Source

Optional features are claimed separately, each with its own test cases:

Feature What it adds
a host language running components defined in that language (js, ts, jsx, tsx, svelte), one per claim; a component that throws while rendering is W118
component analysis checking each host-language component against its signature fence (W104, section 7.1)
remote data loading https:// data (section 8.6)
interactive inputs readers change inputs, and the processor renders again (section 8.7)
origins origin on every expanded node, for editors (section 12.3)

A claim names the class and its features, for example "compmd/1 processor with jsx and remote data".

What a processor that renders MUST do (render fallbacks, escape interpolated text, show inputs statically when it isn't interactive) is required by this spec, but not tested by the conformance set.

Layout. The meaning of each layout param is the CSS property it names (section 9). Conformance is tested on the expanded tree, not on output. A reference HTML and CSS rendering of the built-ins, @compmd/render's (README), is informative.

13.2 The conformance set#Source

Each case is a folder, since imports and data need several files:

conformance/<area>/<case>/
  case.json          what the case needs: {"requires": ["processor", "remote-data"]}
  input.comp.md       plus any imported documents and data files
  context.json       optional: render time, time zone, input values, host metadata, URL-to-file map
  parse.json         the expected tree after stage 1
  expanded.json      optional: the expected tree after stage 4b
  diagnostics.json   optional: the expected diagnostics, [{code, file, line, column, related}]; none when absent

14. Degradation contract#Source

A CompMD document rendered by a plain CommonMark or GFM renderer MUST keep all author content readable. Conformance test suites check these rules against the CommonMark and GFM reference renderers.

  1. CompMD syntax never hides author content: everything inside a container renders as normal Markdown.
  2. Every CompMD construct is one line, so a plain renderer shows at most one literal line per construct, never a block of code or a broken table.
  3. Component lines never change how the surrounding Markdown parses: no significant indentation, no syntax that opens a code block or HTML block.
  4. Leaf and inline components carry readable fallback text.
  5. Definitions show as syntax-highlighted code, or live in imported files.

Generated content is exempt. Output produced by each, if or value from data cannot be readable in a plain renderer, which shows the template once (for example - {{c.text}}). Authors who need plain-renderer readability write that content statically.

What a plain renderer shows:

CompMD construct Plain rendering
::: card {title="Setup"} one literal text line
container body rendered Markdown, unchanged
:: #footer one literal text line
::: /card one literal text line
:: chart[Weekly installs] {…} one literal line containing the fallback
:badge[beta]{tone=info} literal text, fallback visible
{{md.doc.version}} literal text
component= definition fence highlighted code block
frontmatter a metadata table (GitHub), hidden (renderers that read frontmatter), or a horizontal rule and a heading (plain CommonMark)

Authors SHOULD separate component lines from surrounding paragraphs with blank lines. Otherwise CommonMark joins a component line to the paragraph above it, which can change the structure around it (two lists become one), and processors report W108 (section 3.2).

15. Security#Source

Any component definition that runs code (jsx, tsx, svelte, vue) MUST run sandboxed, wherever it runs: in a reader's browser, on a build server, in an application. This applies to inline definitions and to imported ones.

The sandbox. The spec states what a sandbox guarantees, not how it is built. Code from a host-language component:

  1. Sees only what the host contract gives it (section 7.4): its params, its slots, its target element, set for its bound inputs, and host when the processor trusts its pack. Not the rest of the document, its data, or the page around it.
  2. Has no network: no requests, images, fonts, style sheets or sockets from anywhere. Its code and assets come from its pack. Data reaches a component only as params, from data imports (section 8.6), which are declared in the frontmatter and cannot carry md.data or inputs the component can change (F031).
  3. Can't reach the reader's or the host's session: no cookies, storage, credentials, host APIs, file system, environment variables or processes.
  4. Can't navigate or open windows. A link in a component doesn't open on its own; a processor MAY offer to open it after showing the reader the full URL. Links written in Markdown are unaffected.
  5. Can be stopped: a processor SHOULD stop a component that runs too long and render its fallback (W118).

Components the host trusts. A component given host (section 7.4) acts with someone's access, but the document that invokes it, and so its params, may be someone else's: a shared document can aim a trusted component at what it shouldn't touch.

Privacy. A fetch made while a reader views a document tells the origin the reader's address, the time, and any input values in the URL, as an image in any Markdown does. A document rendered once at build time reveals no reader. For fetches at view time:

16. Example#Source

Release notes as an author would write them. The sections above show each construct on its own. A complete example that uses every construct, with its data files, is in examples/complete/.

---
md:
  version: 1
  import:
    - from: "@myorg/ui@1"
      names: [card, badge]
    - from: ./chart.jsx
      names: [chart]
title: Release notes
version: "1.4"
changes:
  - {text: "New wgpu renderer", pr: 412, breaking: false}
  - {text: "Config format 2", pr: 398, breaking: true}
---

# {{md.doc.title}} :badge[beta]{tone=info}

RayPlay {{md.doc.version}} ships a new renderer.

```compmd component=callout {padding=m border-radius=m border=subtle background=subtle class=("callout-" + kind)}
params:
  kind: tip | warning = tip
slots: [default]
---
{{md.slot}}
```

::: grid {gap=l}
::: + md:grid-template-columns="2fr 1fr"

::: card {title="Setup" icon=rocket}
Install with `cargo install rayplay`, then run `rayplay init`.

:: #footer
Takes about 2 minutes.
::: /card

:: chart[Weekly installs: up 18% since 1.3] {src=installs.csv type=bar}

::: /grid

## What changed

::: each {in=(md.doc.changes) as=c join=list}
- {{c.text}} ([#{{c.pr}}](https://github.com/acme/rayplay/pull/{{c.pr}}))
::: /each

::: if {when=(md.doc.changes.exists(c, c.breaking))}
::: callout {kind=warning}
The 1.3 config format is no longer read. Run `rayplay migrate` first.
::: /callout
::: /if

In a plain renderer, this shows the title and badge text, the release sentence, the definition as a code block, the card, chart and callout text in order with one literal component line around each, and the change list's template once (section 14).

17. Open issues#Source