Skip to content

Coming from Markdown ​

Who this is for

Authors who already write CommonMark or GFM and want to move documents across. Task-oriented: what to change, not why it differs.

This guide is for authors who already know CommonMark or GitHub-Flavored Markdown (GFM) and want to rewrite documents in Carve. It focuses on what to change, not on why Carve differs from Markdown - for the design rationale see Carve vs Markdown/Djot/MDX.

Automated conversion

The carve-js, carve-rs and carve-php CLIs share the same command. It reads a named file (or standard input when the file is omitted) and writes Carve to standard output:

sh
carve migrate --from markdown input.md > input.crv

For library use, call markdownToCarve from @markup-carve/carve, carve::markdown_to_carve in Rust, or (new \MarkupCarve\Carve\Converter\MarkdownToCarve())->convert() in PHP. For example:

js
import { markdownToCarve } from '@markup-carve/carve'

const carve = markdownToCarve(markdownSource)

The converter handles most mechanical substitutions, but review the output for emphasis and table syntax.

Syntax map ​

The table below covers the constructs you use most often. Items marked same work identically; items marked changed need attention.

ConstructMarkdownCarveNotes
Headings# H1 through ###### H6sameSingle-line, as in Markdown. A trailing {#id} is not an attribute (see below)
Links[text](url)same
Reference links[text][ref] / [ref]: urlsame
Images![alt](src)same
Inline code`code`same
Fenced code`code fence` with a languagesameWrite the language next to the fence; a space is also accepted
Blockquotes> textsameCarve adds captions (see below)
Unordered lists- item or * itemsameCarve bullets are -/*; a Markdown + bullet is not a Carve bullet
Ordered lists1. item1. item or . itemNumbered markers work in both languages; . asks Carve to number the list from 1
Task lists- [ ] todo / - [x] donesame
Thematic break---sameContiguous ---, ***, or ___ (no spaced forms)
Italic*italic* or _italic_/italic/Changed - see below
Bold**bold** or __bold__*bold*Changed - see below
Bold + italic***both***/*both*/Changed
Underline(not standard)_underline_Carve adds this
Strikethrough~~strike~~ (GFM)~strike~Single tilde in Carve
TablesGFM pipe tables with a |---| row|= header cellsChanged - see below (GFM delimiter row also accepted)
Footnotes[^label] + [^label]: text (GitHub extension)samePlus inline ^[...]
Raw HTMLInline and block, on by defaultImported, not dropped: block to a =html block, native inline (<b>, <code>, ...) to its Carve construct, other inline to `...`{=html}Changed - see below
Keys, abbreviations, datesraw <kbd>, <abbr title="…">, <time datetime="…">[Tab]{kbd}, [HTML]{abbr="…"}, [today]{time="…"}Carve adds this - raw HTML is off, so a span attribute is how you reach those elements. Three are core (abbr, time, kbd); samp, var, cite and dfn need the SemanticSpan extension

Emphasis: the most important change ​

Carve uses / for italic and * for bold - unlike Markdown's */**. And _, which is italic in Markdown, means underline in Carve. This is the one change that will catch you most often.

md
*italic text*
**bold text**
***bold and italic***
_also italic_
__also bold__
~~strikethrough~~
carve
/italic text/
*bold text*
/*bold and italic*/
_underline_
*also bold*
~strikethrough~

Emphasis is the most common migration error

The auto-converter rewrites emphasis for you, but watch for cases where your Markdown used _ for italics - in Carve _underline_ renders as <u>, not <em>.

The mnemonic: / leans like italics, * is strong like bold.

Fenced code blocks ​

Fenced code blocks work like Markdown in the common case. Put the language directly after the opening fence:

md
```python
def hello():
    print("hello")
```

Carve is lenient about the leading space: one space after the fence (the Djot style) is also accepted and parses identically. Exactly one - the slot is spelled [space], so two spaces are not padding and the line is not a fence opener at all. One difference worth knowing: Carve's info string is structured - an optional language token, then an optional "title", then an optional [label] - rather than free-form text. For everyday language-only fences there is nothing to migrate.

Tables ​

GFM tables use a delimiter row (|---|) to mark the header. Carve marks each header cell with |= and needs no delimiter row:

md
| Name    | Role    |
|---------|---------|
| Alice   | Author  |
| Bob     | Editor  |
carve
|= Name   |= Role   |
| Alice   | Author  |
| Bob     | Editor  |

Carve also accepts a GFM |---| delimiter row as the second line, so many pasted Markdown tables render unchanged. Carve's formatter and converters write the |= form.

Note the space after |=. A cell marker is glued to the pipe and ends at a space, so |=Name | is a data cell whose text is =Name, not a header.

Carve tables also support cell spanning and multi-line cells:

carve
|= Name        |= Q1 |= Q2 |
| Alice         | 42  | 17  |
| Bob           | 9   | ^   |
| Carol and Dan | <   | 21  |
  • < merges with the nearest available cell to its left (colspan)
  • ^ merges with the nearest available cell above (rowspan)
  • + begins a continuation row: each non-empty cell is appended to the corresponding cell in the row above (multi-line cells), not a span

Blockquote captions ​

Carve blockquotes work the same as Markdown, but you can add a caption with a ^ line immediately after the quote:

carve
> The art of being wise is the art of knowing what to overlook.
^ William James, /The Principles of Psychology/

The same caption syntax works after images and fenced code blocks.

Things Markdown does not have ​

These Carve features have no Markdown equivalent. They do not collide with Markdown syntax you already know.

Admonitions ​

carve
::: note
This is a note admonition.
:::

::: warning
Dangerous operation ahead.
:::

The built-in callout names are note, tip, warning, danger, info, success, example, and quote. They render as <aside> elements. Another ::: name, such as important, renders as a <div> with that class unless an enabled extension handles the name.

A custom title goes in straight double quotes after the type:

carve
::: tip "Custom Title"
The quoted header renders as the admonition's title.
:::

VitePress and Docusaurus accept an unquoted title such as ::: tip Custom Title. Carve does not: it treats those lines as ordinary text. Write ::: tip "Custom Title". Typographic quotation marks such as “Custom Title” are not accepted here; use straight quotation marks.

Attributes ​

Attach {#id .class key="value"} to inline elements directly, and to block elements via a standalone line before the block:

carve
A [span]{.highlight} with a class, or an attributed [link](/url){.cta}.

{#custom-id .callout}
::: note
This note has an id and a class, set by the line above it.
:::

A {...} written at the end of a heading line is not an attribute (a deliberate Djot-strict choice). To give a heading a custom id, put the attribute on the preceding line:

carve
{#intro}
## Introduction

This is the migration trap most likely to bite you, because it fails quietly. {#id} at the end of a heading is the kramdown and Pandoc spelling, so it is all over existing Markdown - and left in place it does not stay inert. ## Introduction {#intro} renders as:

html
<section id="Introduction-intro">
  <h2>Introduction {<span class="tag"><strong>#intro</strong></span>}</h2>
</section>

Two things happened, neither of them an error:

  • #intro is a tag in Carve, so it renders as a tag chip inside the heading rather than as the characters you wrote.
  • The auto-generated id is derived from the heading text, and that text now includes the tag - so the anchor is Introduction-intro, not Introduction and not intro. Every inbound link to either breaks, and the page still renders.

carve lint reports it, which is the cheapest way to find them all before you publish:

1:17  heading-trailing-attribute  Trailing "{#intro}" on a heading is literal
      text in Carve, not an attribute block. Move it to a "{#intro}" line
      directly above the heading.

Without an explicit id, headings still get an auto-generated id from their text (case is preserved; a leading digit gets an s- prefix). Lowercasing and ASCII-folding are available as opt-in transforms.

Math ​

carve
Einstein's famous equation is $`E = mc^2`.
carve
$$`\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}`

Footnotes ​

Carve supports both reference-style and inline footnotes:

carve
Reference-style[^1] and inline^[This is the footnote content.] both work.

[^1]: Content for the reference footnote.

Citations ​

Citations are an optional feature. After enabling them, cite with [@key] and define entries with [@key]: lines. The reference list appears at the end of the document, or at the position of a ::: references block:

carve
Recent work [@smith2023] shows promising results.

[@smith2023]: Smith, J. (2023). /Example Paper/. Journal of Examples.

Cross-references ​

carve
{#intro}
## Introduction

See [the introduction](#intro) or the cross-reference </#intro>.

</#id> inserts a cross-reference to the target: generated link text for a heading, or an auto-updating number ("Figure 3", "Table 2", "Listing 1", "Equation 4") for a numbered-caption target - a figure, table, listing, or equation whose caption carries a number placeholder.

Extension syntax ​

The :name[content] (inline) and ::: name (block) syntax is available for custom extensions without touching core grammar. An unknown inline :name[content] renders as a generic inline extension (class ext-name, content closing at the first ]); an unknown ::: name block renders as a generic typed div - so documents stay readable even without the extension registered.

Raw HTML ​

Bare <span> and <div> tags in hand-written Carve source are always literal text - they are never interpreted as HTML. This is the key safety difference from Markdown, which passes raw HTML through by default.

Migrating from Markdown does not leave the HTML behind, though: the importer preserves it rather than dropping it to literal text. A block-level element becomes a ```=html block; a native inline tag (<b>, <strong>, <i>, <em>, <code>, <mark>, <sup>, <sub>, <del>, <s>, <ins>) becomes its Carve construct; and any other inline tag becomes a `...`{=html} span. So <span>note</span> in your Markdown arrives as `<span>note</span>`{=html} (kept verbatim) and <b>bold</b> as *bold* (rendered <strong>).

When you want verbatim HTML, use the explicit raw constructs - a ```=html block or `...`{=html} inline. These passthrough constructs are on by default for trusted content. For untrusted input, turn the passthrough off so even those are escaped:

ts
import { carveToHtml } from '@markup-carve/carve'

// Untrusted input: disable the explicit raw-HTML passthrough.
const html = carveToHtml(source, { allowRawHtml: false })

The equivalent switch exists in each full implementation: carve-js uses allowRawHtml: false, carve-rs uses Options::with_raw_html(false), and carve-php enables a SafeMode (its default safe mode escapes raw HTML). All three CLIs accept --safe; the JS and Rust CLIs also spell the narrower switch --no-raw-html. See Security for the full model.

Trust boundary

Bare tags are safe (literal) regardless. The setting above only governs the explicit ```=html / {=html} passthrough - leave it disabled for user-generated content.

Headings are wrapped in <section> ​

This is the one output change that can break a site whose source migrated cleanly, so check it before you convert a whole content directory.

A Markdown renderer emits headings flat. Carve wraps each heading, and the content following it up to the next same-or-shallower heading, in a <section> - and the heading's id goes on that wrapper:

html
<!-- Markdown -->
<h2 id="page-heading">Page Heading</h2>
<p>A paragraph.</p>

<!-- Carve -->
<section id="Page-Heading">
  <h2>Page Heading</h2>
  <p>A paragraph.</p>
</section>

Moving an id from the heading to its <section> does not by itself affect a fragment link: once the Carve id is Page-Heading, #Page-Heading resolves to the wrapper exactly as it would to the heading. Migration can still change the id itself. Many Markdown renderers lowercase this example to page-heading, while Carve preserves case by default, so audit existing inbound links or give the heading an explicit id. What the wrapper additionally breaks is CSS and JS that assume rendered blocks are direct children of their container. The common casualty is the owl/stack spacing idiom, because the paragraphs are now grandchildren:

css
/* Stops matching: the section is the only direct child. */
.stack > * + * { margin-block-start: 1.5em; }

/* Fix: match inside generated sections at any depth. */
:where(.stack, .stack section) > * + * { margin-block-start: 1.5em; }

Other things worth grepping your stylesheets and scripts for: > child combinators under your content wrapper, :first-child / :last-child (the first paragraph after a heading is now the section's second child), :nth-child() counting, and element.children walks over the rendered container.

If a project cannot absorb the shape change, an HTML renderer MAY offer a sections option that turns the wrapper off, putting the id back on the <h*>:

ts
const html = carveToHtml(source, { sections: false })
html
<h2 id="Page-Heading">Page Heading</h2>
<p>A paragraph.</p>

Nothing else changes when it is off - ids, dedup, </#id> cross-references, [Heading][] references, and ::: toc all resolve against the slug, not the element carrying it. Check your engine's release notes for whether it ships the option yet; the wrapper remains the tested default across implementations.

Two related shapes are worth knowing while you audit selectors. A heading inside a blockquote, div, admonition, or list item is never wrapped - it emits <h* id="…"> in place, which is also exactly what every heading looks like with sections: false. And on a wrapped heading only the id hoists: {#install .featured} gives <section id="install"><h2 class="featured">, so a class you attached to a heading still selects the heading.

Shared importer targets ​

Importers preserve the source format's meaning within Carve's spelling limits. The writer ceiling in PART 11 §1c also applies to Markdown and Djot imports: an unspellable inner wrapper is unwrapped, its content stays in order, and an importer with a diagnostic channel reports structure-unspellable.

For example, CommonMark *(*foo*)* contains nested emphasis. Its shared Carve fallback is /(foo)/, which renders as <em>(foo)</em>. Turning the outer emphasis into strong, or dropping the emphasis around foo, changes more than the unspellable nesting and does not meet this target. The equivalent HTML input uses the same fallback.

Djot inline attributes belong to the element they annotate. Importers must parse that association before writing Carve. For a *b{#id key="*"}*, the target is a strong span containing a span with id id, attribute key="*", and text b. One Carve spelling is a {*[b]{#id key="*"}*}. The * inside the attribute value must not close the strong span. Copying the Djot braces as text, or escaping them and dropping the attributes, does not preserve this representable structure.

These are shared targets, not a claim that each importer already meets them. The import comparison gate records current gaps and rejects changed or undeclared differences.

Migration checklist ​

When moving a document from Markdown to Carve:

  • [ ] Run carve migrate --from markdown input.md > input.crv for a first-pass conversion (or use the equivalent JS, Rust or PHP library API)
  • [ ] Review all emphasis: *italic* -> /italic/, **bold** -> *bold*
  • [ ] Check _underline_ occurrences - these render as <u> in Carve, not <em>
  • [ ] Verify GFM table delimiter rows became |= header cells
  • [ ] Verify ~~strike~~ became ~strike~ (single tilde)
  • [ ] Move any heading {#id} onto the line above the heading - carve lint finds them, and left in place the #id becomes a tag AND changes the heading's anchor
  • [ ] Check block-marker indentation: top-level markers require column 0. Inside a container, a recognized opener must reach the innermost container's minimum content column; a deeper opener is structural too, and carve fmt moves it back to the minimum column
  • [ ] Review imported raw HTML: block elements become =html blocks, native inline tags become their Carve constructs, and other inline tags become `...`{=html} spans - all preserved automatically, no manual replacement needed. For untrusted input, use the engine's safe mode so the passthrough is escaped
  • [ ] Audit CSS and JS for direct-child assumptions - headings now nest their content in <section> (see Headings are wrapped in <section>)

Released under the MIT License.