Bundled extensions

August 28, 2026 · View on GitHub

This page documents the extensions shipped with carve-php. The normative, language-level extension contract (taxonomy, matcher/transform/renderer stages, registration) lives upstream in carve/docs/extensions.md.

Default vs opt-in

Two extensions are part of the core Carve language and are registered automatically on the first parse()/convert() call, so they are active out of the box without any addExtension() call:

  • FrontmatterExtension - a leading ---yaml ... --- block is treated as document metadata and stripped from the rendered output (not a thematic break).
  • MentionsExtension - @mentions and #tags are parsed as core social syntax.

Both are registered lazily: if you add your own pre-configured instance (for example new MentionsExtension(mentionUrl: '/users/{name}')) before the first parse, your instance takes precedence and the default is not added. Configure extensions before the first parse(); the standard extension lifecycle expects all extensions to be in place before parsing begins.

Every other extension below is opt-in and must be registered manually.

Adding an extension

All extensions are registered through addExtension():

use MarkupCarve\Carve\CarveConverter;
use MarkupCarve\Carve\Extension\ExternalLinksExtension;

$converter = new CarveConverter();
$converter->addExtension(new ExternalLinksExtension());

$html = $converter->convert($source);

For each opt-in extension below, the registration call is simply addExtension(new FooExtension(...)). Per-extension examples are shown only where the syntax or options add value.

Note: HeadingReferenceExtension and WikilinksExtension both parse [[...]] syntax and therefore cannot be registered on the same converter instance - doing so throws a LogicException.

Static render mode

A render carries a mode - a render option, not document syntax:

  • RenderMode::INTERACTIVE (the default) - online HTML; extensions render their interactive form (live tabs, mermaid via a client script, \[ ... \] math for KaTeX/MathJax).
  • RenderMode::STATIC - HTML for a medium that cannot interact or run client scripts (print, PDF source, archival HTML).

Omitting the mode means interactive, so existing callers are unaffected. An unknown mode value is rejected (InvalidArgumentException); print, email and similar are reserved for future named presets.

use MarkupCarve\Carve\CarveConverter;
use MarkupCarve\Carve\Renderer\RenderMode;

$converter = new CarveConverter(mode: RenderMode::STATIC);
// or: $converter->setRenderMode(RenderMode::STATIC);

The MarkdownRenderer, PlainTextRenderer and AnsiRenderer are inherently static; they flatten interactive constructs and keep client-script blocks as source regardless of this option (the mode option is HTML-only).

Resolution order (renderStaticHtml)

In static mode, an extension renders through an optional static-HTML path (StaticRenderExtensionInterface::renderStaticHtml()). Per node:

  1. the extension's renderStaticHtml(), if it claims the node;
  2. else the extension's ordinary renderer (correct for extensions that are already static - list-table, citations, heading-permalinks - which need no static path);
  3. else, for a fenced div whose grouping [label] no extension consumed, the core caption floor (<p class="div-label">, see Graceful Degradation).

No construct falls through to "dropped": every authored token reaches at least the floor.

The renderers map (client-script extensions)

Client-script extensions (mermaid, chart, plantuml, math, …) cannot produce their visual inside the engine. A static render therefore accepts a renderers map of source -> string callables. The map is open: a diagram renderer is keyed by the fence's css class, so a custom FencedRender fence word is static-capable with no engine change. When the needed renderer is absent, the static path falls back to source, never blank.

use MarkupCarve\Carve\CarveConverter;
use MarkupCarve\Carve\Renderer\RenderMode;

$converter = new CarveConverter(mode: RenderMode::STATIC, renderers: [
    'math' => fn (string $tex): string => $katex->renderToString($tex),
    'mermaid' => fn (string $src): string => $mmdc->renderSvg($src),
    // a custom fence word works the same way, keyed by its css class:
    'myuml' => fn (string $src): string => $myuml->renderSvg($src),
]);
// or: $converter->setRenderers([...]);

Which extensions carve-php applies renderStaticHtml to

carve-php ships these interactive extensions; the table is their static output:

ExtensionStatic (renderStaticHtml) output
TabsExtensioneach panel as a <section class="tabs-panel"> headed by <h3 class="tabs-label"> carrying the tab label (no radio inputs, no tab buttons)
CodeGroupExtensioneach code block as a <section class="code-group-panel"> headed by <h3 class="code-group-label"> carrying its [label]
MathBlockExtensionthe math renderer's server-side output (MathML/HTML) inside <div class="math display">, else the LaTeX source inside <pre class="math display">
FencedRenderExtension (mermaid, chart, ...)the renderer's image inside a <div class="mermaid"> named for the fence's CSS class, else the source inside <pre class="mermaid"> holding <code class="language-mermaid">
ImgFenceExtensionwhat it emits interactively, unchanged: the sanitized SVG in a sandboxed <img> carrying a data:image/svg+xml URI - or inline <svg>, but only where the host passed allowInline: true AND the block attribute line carries {inline}. A body the sanitizer rejects falls back to the source inside <pre> holding <code class="language-img">
SpoilerExtensionblock: a revealed <section class="spoiler spoiler-revealed"> headed by <h3 class="spoiler-title">, with any grouping [label] following as a <p class="div-label"> caption; inline: <span class="spoiler spoiler-revealed">

DetailsExtension reaches the same end without implementing the interface: it asks the renderer for the mode directly and appends open, because the page calls a disclosure a special case that is kept, not flattened.

This paragraph used to read "DetailsExtension and SpoilerExtension already degrade natively ... so they need no separate static path". That was false for the spoiler, and it is why the gap stood: a <details> carrying no open renders COLLAPSED in a print engine, so the body never reaches the page. The disclosure escapes that only because it forces open.

The ListTableExtension, citations and heading-permalink extensions are already static and render identically in both modes (resolution step 2).

Extensions that exist in the carve-js reference but not in carve-php (a standalone non-<details> Details/Spoiler shape) gain a renderStaticHtml in carve-php only when/if carve-php gains the extension; until then the same graceful-degradation guarantee is met by the core caption floor (step 3).

The CLI exposes the mode via bin/carve --static (HTML format only); see examples/static-render-demo.php for a runnable interactive-vs-static demo.

AutolinkExtension

Converts bare URLs in text (http://, https://, mailto:, and bare email addresses) into clickable links without explicit link syntax.

Constructor options:

  • allowedSchemes (array<string>, default ['https', 'http', 'mailto']) - which URL schemes to auto-link. When mailto is included, bare email addresses are also linked.
$converter->addExtension(new AutolinkExtension());

$converter->convert('Visit https://example.com for more info.');
// <p>Visit <a href="https://example.com">https://example.com</a> for more info.</p>

// Only http/https, no mailto / bare emails:
$converter->addExtension(new AutolinkExtension(allowedSchemes: ['https', 'http']));

ExternalLinksExtension

Adds target and rel attributes to external links (URLs starting with http:// or https://). Hosts you list as internal are left untouched.

Constructor options:

  • internalHosts (array<string>, default []) - hosts treated as internal (no external attributes added).
  • target (string, default '_blank').
  • rel (string, default 'noopener noreferrer').
  • nofollow (bool, default false) - append nofollow to the rel value.
$converter->addExtension(new ExternalLinksExtension(
    internalHosts: ['example.com', 'www.example.com'],
    nofollow: true,
));

WikilinksExtension

Parses [[Page Name]] and [[page|Display Text]] (with optional #anchor) into navigational links, as used in wikis and note tools like Obsidian or MediaWiki. Cannot be combined with HeadingReferenceExtension.

Constructor options:

  • urlGenerator (?Closure, default null) - fn(string $page): string. When null, the page name is slugified.
  • cssClass (string, default 'wikilink').
  • newWindow (bool, default false).
$converter->addExtension(new WikilinksExtension(
    urlGenerator: fn (string $page) => '/wiki/' . strtolower(str_replace(' ', '-', $page)) . '.html',
));

$converter->convert('See [[Tiger Facts]]');
// <p>See <a href="/wiki/tiger-facts.html" class="wikilink">Tiger Facts</a></p>

$converter->convert('Learn about [[tigers|big cats]]');
// <p>Learn about <a href="tigers" class="wikilink">big cats</a></p>

HeadingReferenceExtension

Resolves [[Heading Text]] (and [[Heading Text|display]]) to in-document headings, matching on the heading's text rather than its generated id, so authors do not have to guess slug rules. HTML output only; with other renderers the [[...]] is rendered as literal text. Cannot be combined with WikilinksExtension.

Constructor options:

  • cssClass (string, default 'heading-ref').
$converter->addExtension(new HeadingReferenceExtension());

MentionsExtension (default)

Parses @mentions and #tags as core Carve social syntax. Active by default. By default both render as non-link spans; pass URL templates with a {name} placeholder to render links instead.

Constructor options:

  • mentionUrl (string, default '') - URL template for mentions; empty means render a non-link span.
  • tagUrl (string, default '') - URL template for tags; empty means a non-link span.
  • mentionClass (string, default 'mention').
  • tagClass (string, default 'tag').
// Default (active out of the box): non-link spans
$converter->convert('Hey @alice, see #release-1.0.');
// <p>Hey <span class="mention"><strong>@alice</strong></span>,
//    see <span class="tag"><strong>#release-1.0</strong></span>.</p>

// Render as links instead (add before the first parse):
$converter->addExtension(new MentionsExtension(
    mentionUrl: '/users/{name}',
    tagUrl: '/tags/{name}',
));

Headings

AsciiHeadingIdsExtension

Folds auto-generated heading ids to ASCII (opt-in). By default Carve heading ids are lowercased but keep non-ASCII characters verbatim (# Über uns -> über-uns). This extension transliterates the slug to ASCII before lowercasing (# Über uns -> uber-uns), useful for share-safe URL fragments. Unmapped scripts (CJK, Arabic, Greek) still pass through unchanged; attach an explicit {#id} for those. The same transform is applied to the parse-time tracker so implicit [Heading][] references resolve to the folded ids.

Constructor options:

  • transliterator (?AsciiTransliterator, default null) - supply a custom transliterator; defaults to new AsciiTransliterator().
$converter->addExtension(new AsciiHeadingIdsExtension());

HeadingLevelShiftExtension

Shifts heading levels down (h1 -> h2, h2 -> h3, and so on). Useful when h1 is reserved for the page title. Levels are capped at h6. Works with all renderers.

Constructor options:

  • shift (int, default 1) - number of levels to shift; clamped to 0-5.
$converter->addExtension(new HeadingLevelShiftExtension(shift: 1)); // h1 -> h2
$converter->addExtension(new HeadingLevelShiftExtension(shift: 2)); // h1 -> h3

HeadingPermalinksExtension

Appends (or prepends) a clickable anchor link to each heading so users can link straight to a section. HTML output only.

Constructor options:

  • symbol (string, default '¶') - the displayed symbol ('#', '🔗', etc.).
  • position (string, default 'after') - 'before' or 'after'.
  • cssClass (string, default 'permalink').
  • ariaLabel (string, default 'Permalink').
  • levels (array<int>, default [1, 2, 3, 4, 5, 6]) - which levels get a permalink.
  • showOnHover (bool, default false) - adds a permalink-hover class to the wrapper for CSS-driven hover reveal.
  • copyToClipboard (bool, default false) - adds a data-permalink-copy attribute for a JS copy-to-clipboard handler.
$converter->addExtension(new HeadingPermalinksExtension(
    symbol: '#',
    position: 'before',
    showOnHover: true,
));

TableOfContentsExtension

Extracts headings and builds a structured table of contents. It can auto-insert the TOC into the output, or expose the data/HTML for custom placement. HTML output only.

Constructor options:

  • minLevel (int, default 1) / maxLevel (int, default 6) - heading level range to include.
  • listType (string, default 'ul') - 'ul' or 'ol'.
  • cssClass (string, default 'toc').
  • position (?string, default null) - 'top', 'bottom', or null for manual placement.
  • separator (string, default '') - HTML inserted between TOC and content when position is set.
  • collapsible (bool, default false) - wrap the TOC in a <details>/<summary> disclosure so it can be collapsed. When off (the default) the output is the named <nav class="toc"> below.
  • summary (string, default 'Table of Contents') - disclosure label, used only when collapsible is true.
  • open (bool, default false) - render the disclosure expanded by default, used only when collapsible is true.

The nav says what it is called

<nav> is a navigation landmark unconditionally, so an unnamed one is an entry in a reader's landmark list reading only "navigation" - and a page holds more than one the moment TocPlacementExtension is registered beside this one, a document writes ::: toc twice, or a site template contributes its own. Both extensions therefore write an aria-label on the nav, from the converter's labels map under tocNav (default Table of contents), so one map localizes the whole document and the two navs stay byte-identical:

<nav class="toc" aria-label="Table of contents">
<ul> ... </ul>
</nav>

An aria-label (or aria-labelledby) the author wrote on a ::: toc block wins and nothing is added beside it - the attribute name is matched case-insensitively, and the author's own spelling is what renders. Set the map entry to '' to emit no name at all.

The summary string below is not this name wearing a second hat: the disclosure shape has no <nav> at all, so one is a landmark's accessible name and the other visible text in a widget, and they never appear together.

When collapsible is on, the heading list sits directly inside the disclosure:

<details class="toc">
<summary>Table of Contents</summary>
<ul> ... </ul>
</details>
$toc = new TableOfContentsExtension(minLevel: 2, maxLevel: 3, position: 'top');
$converter->addExtension($toc);

$html = $converter->convert($source);

// Or place it yourself:
$tocHtml = $toc->getTocHtml();        // nested list HTML
$tocData = $toc->getToc();            // [['level' => 1, 'text' => '...', 'id' => '...'], ...]

Blocks and divs

AdmonitionExtension

Turns standard Carve divs (::: note, ::: warning, etc.) into semantic admonition markup with accessibility roles. warning and danger get role="alert". A {title="..."} attribute overrides the heading.

For disclosure/collapsible widgets use the separate DetailsExtension (::: details "title"). This extension does not produce <details>; any {collapsible} attribute is passed through as an ordinary HTML attribute.

Constructor options:

  • types (array<string>, default ['note', 'tip', 'warning', 'danger', 'info', 'success']).
  • defaultTitle (bool, default true) - emit a title from the type when none is given.
  • titleTag (string, default 'p').
  • titleClass (string, default 'admonition-title').
  • containerClass (string, default 'admonition').
  • icons (array<string,string>|bool, default false) - true uses the built-in emoji icon set; an array supplies custom per-type icons.
  • iconClass (string, default 'admonition-icon').
$converter->addExtension(new AdmonitionExtension(icons: true));

Input:

::: note
This is a note.
:::

{title="Watch Out!"}
::: warning
Be careful here.
:::

DetailsExtension

Renders ::: details admonitions as the HTML5 <details>/<summary> disclosure widget instead of the default <div class="details">. The quoted title becomes the <summary>; a title-less block falls back to <summary>Details</summary> so the widget always has an accessible label. That fallback label is configurable via the defaultSummary constructor argument, so a non-English document can label its own disclosures:

$converter->addExtension(new DetailsExtension(defaultSummary: 'Details anzeigen'));

The custom label is escaped as HTML content, and a quoted opener title always wins over it.

The summary renders as escaped plain text. Block attributes on the opener ({#faq open}) carry onto the <details> tag in source order, matching the default <div class="details"> behavior (safe-mode attribute filtering still applies); only the auto details class is dropped because the <details> tag is itself the styling hook. HTML output only.

$converter->addExtension(new DetailsExtension());

Input:

::: details "More info"
Hidden until the reader expands it.
:::

Output:

<details>
  <summary>More info</summary>
  <p>Hidden until the reader expands it.</p>
</details>

Without the extension the same block renders as the default <div class="details"><p class="admonition-title">More info</p>…</div>. Use {open} to expand the widget by default (<details open="">).

ListTableExtension

Renders ::: list-table blocks as real HTML <table> markup, with the table authored as a nested list. Because each cell is a list item, cells can hold full block content (paragraphs, lists, code blocks, …) that the native pipe-table syntax cannot express.

Each outer list item is a row; each inner list item is a cell:

$converter->addExtension(new ListTableExtension());

Important

Attributes go on a preceding line, not the ::: opener. A trailing {...} on the opener makes the whole block literal in Carve. Use {header-rows=1} on its own line above ::: list-table.

Input:

{header-rows=1}
::: list-table "Quarterly results"
- - Region
  - Notes
- - EMEA
  - Strong quarter.

    Drivers:

    - new logos
    - renewals
:::

Output:

<table>
  <caption>Quarterly results</caption>
  <thead><tr><th>Region</th><th>Notes</th></tr></thead>
  <tbody>
    <tr><td>EMEA</td><td><p>Strong quarter.</p>
<p>Drivers:</p>
<ul>
  <li>new logos</li>
  <li>renewals</li>
</ul></td></tr>
  </tbody>
</table>

The quoted title becomes the <caption> (omitted when absent). Two attributes control header promotion (both default 0):

  • header-rows=N promotes the first N rows to <thead> with <th> cells.
  • header-cols=N promotes the first N cells of every row to row-header <th>.
  • The boolean form {header-rows} (no value) means the first row, the common "this table has a header row" case, so you rarely need =1. Likewise {header-cols} promotes the first column. An explicit =N still wins, and an absent attribute means no header.

A cell whose only content is a single plain paragraph collapses to inline content (<td>text</td>), exactly like a tight list item; a cell with multiple blocks keeps its <p>/<ul>/… wrappers (as in the Strong quarter. cell above). This is the core benefit over pipe tables: rich, multi-block cells.

Ragged rows (rows with differing cell counts) are padded with empty <td> to the widest row, so no content is ever silently dropped. Inline markup inside a cell renders normally (`flat` becomes <code>flat</code>). Block attributes on the opener carry onto the <table> tag in source order (safe-mode filtering still applies); the structural title, header-rows, header-cols, and the auto list-table class are consumed by the extension and not emitted. A cell that carries its own list-item attributes (id, classes, key=value) carries them onto its <td>/<th> tag; the computed structural rowspan/ colspan always win, so an author-written rowspan/colspan (in any case) on a cell is dropped rather than duplicated. HTML output only.

If a row is authored without an inner cell list - for example a plain paragraph row like - not-a-cell-row - it cannot become table cells without dropping its text. The whole block then defers to the default renderer and degrades to the literal <div class="list-table"> nested list, so the content is preserved verbatim rather than emitted as empty cells.

Spanning cells (^ rowspan, < colspan)

Cells can span rows and columns using the same continuation markers Carve's native pipe tables use, so the output <table> matches what an equivalent pipe table produces:

  • A cell whose sole content is a lone ^ merges with the cell above (rowspan). A rowspan of N is the cell plus N - 1 ^ cells in the following rows.
  • A cell whose sole content is a lone < merges with the cell to the left (colspan). A colspan of K is the cell plus K - 1 < cells (so colspan=3 is Total, <, <).

A cell carrying its own attribute block (for example -{.x} ^) is never a span marker - its ^/< content stays literal. This is the same escape pipe tables use.

Input:

{header-rows=1}
::: list-table "Sales"
- - Region
  - Q1
  - Q2
- - EMEA
  - 10
  - 12
- - ^
  - 14
  - 16
- - Total
  - <
  - <
:::

Output:

<table>
  <caption>Sales</caption>
  <thead><tr><th>Region</th><th>Q1</th><th>Q2</th></tr></thead>
  <tbody>
    <tr><td rowspan="2">EMEA</td><td>10</td><td>12</td></tr>
    <tr><td>14</td><td>16</td></tr>
    <tr><td colspan="3">Total</td></tr>
  </tbody>
</table>

EMEA's cell gets rowspan="2" (it plus the ^ below it); Total gets colspan="3" (it plus the two <). The column count accounts for spans, so a colspan=K cell fills K columns and a rowspan=K cell reserves its column in the next K - 1 rows; a span that overflows the grid is clamped (and a warning is emitted) rather than corrupting the table. A ^ only continues a cell whose column also existed in the immediately preceding row - below a ragged row that omitted that column it renders as a plain empty cell, never a span across the gap.

A rowspan is clamped at the <thead>/<tbody> boundary: with header-rows=N, a ^ in a body row whose origin cell sits in the header rows does not pull a rowspan across the row-group boundary (an HTML cell cannot reliably span from <thead> into <tbody>). The header cell stays a plain <th> and the ^ degrades to an empty body cell. This is a deliberate divergence from the equivalent pipe table, which has no such row-group boundary. Rowspans that stay entirely within the body (or entirely within the header) are unaffected.

Note

Span resolution matches the pipe table for all well-formed inputs, except for the header/body boundary clamp described above. Heavily overlapping markers (for example a ^ placed inside the interior of an existing rowspan-and-colspan cell) are degenerate and may differ slightly from the equivalent pipe table - the same kind of input the native pipe table itself resolves ambiguously. Ragged rows are padded with empty <td> so the grid stays rectangular (this is the existing list-table behavior, unchanged by spans).

Without the extension the same block degrades gracefully to the default <div class="list-table"> holding the literal nested list, so source is never lost.

TabsExtension

Converts a wrapper div with class tabs containing child tab divs into an accessible tabbed interface. Tab labels come from the first heading or a {label="..."} attribute; {selected} marks the default tab. Supports a CSS-only mode (no JavaScript) and an ARIA mode with keyboard navigation. HTML output only.

Exactly one tab is selected: the first one the document marks {selected}, and the first tab where it marks none. Marking several is not an error and is not diagnosed - the later marks are simply ignored, so both modes open the same tab (Extensions §13.5).

Constructor options:

  • mode (string, default 'css') - 'css' or 'aria'.
  • wrapperClass (string, default 'tabs').
  • tabClass (string, default 'tabs-panel').
  • labelClass (string, default 'tabs-label').
  • radioClass (string, default 'tabs-radio').
  • idPrefix (string, default 'tabset').
  • groupLabel (?string, default null) - the accessible name for the tab set as a whole. Left unset the string comes from the converter's labels map under tabsGroup (default Tabs).

An unknown mode value throws rather than falling back, so a typo cannot become silently different output.

The wrapper carries role and an accessible name: group in CSS mode - which has no tab/panel roles to associate, so a plain grouping is all it can honestly claim - and tablist in ARIA mode. An aria-label or aria-labelledby the author wrote on the block wins over the engine's, and an authored role stands; both attributes are appended, so naming the set never moves an attribute the author placed.

Each panel is named as well, by its own tab's label:

<div class="tabs-panel" role="group" aria-label="First">

Under css every radio and label is emitted before every panel, so nothing binds a panel to the control that reveals it and the panel would otherwise be anonymous. In aria mode it is bound instead of named - role="tabpanel" plus aria-labelledby, and neither role="group" nor an aria-label. This is Extensions §13.

The aria-mode control is a <button type="button">. Without the type a <button> is a submit button, so a tab set inside a <form> submitted the form instead of switching panels (Extensions §13.3). css mode is unaffected: its control is an <input type="radio">.

<button type="button" role="tab" id="tabset-1-tab-1" aria-selected="true" aria-controls="tabset-1-panel-1" class="tabs-label">First</button>

Generated ids (tabset-1, tabset-1-tab-1, ...) are deduplicated against the document id namespace: when an explicit {#id} attribute or a generated heading id already uses a name, the tab set takes the next free suffix (tabset-1-2) instead of emitting a duplicate DOM id. The same applies to CodeGroupExtension group ids and the citation anchor/reference ids (cite-{key}-{n}, ref-{key}).

$converter->addExtension(new TabsExtension()); // CSS-only
$converter->addExtension(new TabsExtension(mode: 'aria'));

Input (the outer container uses :::: so it can hold nested ::: divs):

:::: tabs

::: tab
### First Tab

Content for the first tab.
:::

::: tab
### Second Tab

Content for the second tab.
:::

::::

CodeGroupExtension

Converts a div with class code-group containing several code blocks into a tabbed code interface, ideal for showing the same step in multiple languages. Tab labels come from the code fence info using [Label] suffix syntax, falling back to the language name or "Code N". HTML output only.

Constructor options:

  • wrapperClass (string, default 'code-group').
  • panelClass (string, default 'code-group-panel').
  • labelClass (string, default 'code-group-label').
  • radioClass (string, default 'code-group-radio').
  • idPrefix (string, default 'codegroup').
  • highlighter (?Closure, default null) - fn(string $code, ?string $lang): string to integrate a syntax highlighter.
  • groupLabel (?string, default null) - the accessible name for the code group as a whole. Left unset the string comes from the converter's labels map under codeGroup (default Code examples).
  • mode (string, default 'css') - 'css' or 'aria', the same two modes TabsExtension carries. An unknown value throws.

The wrapper carries role="group" and that name, on the same terms as TabsExtension: an author's role, aria-label or aria-labelledby wins, and the engine's attributes are appended. In aria mode it is role="tablist" instead and keeps the same name.

Each panel is named too, by its own label - the [Label] where one was written, otherwise the language word:

<div class="code-group-panel" role="group" aria-label="Node"><pre><code class="language-js">

role="group" and not role="tabpanel", because under css the control that reveals the panel is a radio rather than a tab. The name is derived from the document, so it has no labels key - an author renames a panel by renaming its tab. In aria mode the panel is BOUND rather than named: it keeps role="tabpanel" with aria-labelledby and takes neither role="group" nor an aria-label, since naming it as well would give one element two accessible names. This is Extensions §13.

Both §13 rules TabsExtension carries bind here too, because §13 binds the two constructs alike: the aria-mode control is a <button type="button"> so that a code group inside a <form> does not submit it, and exactly one panel is selected - the first {selected} mark wins, later marks are ignored, and marking none opens the first panel.

$converter->addExtension(new CodeGroupExtension());

Input:

::: code-group
``` php [Installation]
composer require php-collective/djot
```

``` bash [NPM]
npm install @example/djot
```
:::

When deciding between this and TabsExtension: use CodeGroupExtension for multiple code blocks with labels from language hints; use TabsExtension for arbitrary content with labels from headings/attributes and optional ARIA mode.

CodeCalloutsExtension

Tier-2 (off by default). AsciiDoc-style code annotations: a <n> (ASCII digits) at the end of a fenced-code line renders as a styleable <b class="callout" data-callout="n">n</b> bubble (only the trailing <n> on a line counts; the rest of the code is escaped as usual). A paragraph of <n> text lines immediately following a marked code block binds as <ol class="callouts"> with explicit <li value="n">, so the ordinal matches the bubble even for non-sequential numbers. The list binds only when the code has a marker and every following line is a <n> text item; otherwise the <n> stay literal. Authored block attributes ride onto the <ol> (callouts leading class). HTML output only; non-HTML keeps the <n> literal. See spec §10.

$converter->addExtension(new CodeCalloutsExtension());

Input:

``` js
const x = compute();   <1>
return x * 2;          <2>
```

<1> Runs the expensive step once.
<2> Doubles the result.

FencedRenderExtension

Generic client-rendered fenced-block factory. Claims fenced code blocks by language word and emits one hydration element for a client-side library; the block body is passed through verbatim (no Carve parsing). Mermaid is just one preset of this client-hydration shape, generalized so D2, Graphviz, WaveDrom, ABC, Vega-Lite, Chart.js, etc. need no new code. HTML output only. Tier-3 (opt-in, never corpus-pinned).

Constructor options:

  • language (string|array<string>, required) - fence info word(s) claimed.
  • cssClass (string, default first language word) - class on the element.
  • tag (string, default 'div' for json mode else 'pre') - wrapper element.
  • contentMode (string, default FencedRenderExtension::MODE_TEXT) - MODE_TEXT or MODE_JSON (see below).
  • wrapInFigure (bool, default false) - wrap in <figure class="{cssClass}-figure">.
  • figureClass (string, default '{cssClass}-figure').

Content modes:

  • MODE_TEXT (Mermaid, D2, Graphviz, WaveDrom, ABC): body is HTML-escaped text inside the wrapper. & and < are escaped (blocking tag injection), but > is preserved so arrow syntax (-->) survives.

    ``` d2
    a -> b
    ```
    

    renders as <pre class="d2">a -> b</pre>.

  • MODE_JSON (Vega-Lite, Chart.js): body is emitted verbatim inside a <script type="application/json"> (default wrapper <div>). Any </ in the body is rewritten to <\/ so the JSON cannot close the script element early (byte-equivalent JSON).

    ``` vega-lite
    {"mark": "bar"}
    ```
    

    renders as <div class="vega-lite"><script type="application/json">{"mark": "bar"}</script></div>.

    Note: json mode emits a <script type="application/json">. If you sanitize the HTML after converting, that inert script is usually stripped - whitelist <script type="application/json"> in your sanitizer, or render the config in text mode so it rides in a <pre> as escaped text (read it from textContent):

    // Text mode (not the json-mode chart() preset) so the config rides in
    // <pre class="chart"> as escaped text and survives HTML sanitizing; the
    // json preset's inert <script type="application/json"> wrapper would be
    // stripped.
    $converter->addExtension(new FencedRenderExtension(
        language: 'chart',
        contentMode: FencedRenderExtension::MODE_TEXT,
        cssClass: 'chart',
    ));
    

Built-in presets (each a one-line factory): mermaid(), d2(), graphviz() (claims dot + graphviz), wavedrom(), abc(), plantuml() (claims plantuml + puml), vegaLite(), chart().

use MarkupCarve\Carve\Extension\FencedRenderExtension;

$converter->addExtension(FencedRenderExtension::mermaid());
$converter->addExtension(FencedRenderExtension::d2());
$converter->addExtension(FencedRenderExtension::vegaLite());
$converter->addExtension(new FencedRenderExtension(language: ['dot', 'graphviz'], cssClass: 'graphviz'));

The mermaid() preset emits <pre class="mermaid">…</pre> from a ``` mermaid fence; you must load Mermaid.js on the page to render the diagrams. It accepts wrapInFigure, tag, cssClass, and figureClass for the same customization the other text-mode presets allow.

To turn them all on without listing each, FencedRenderExtension::presets() returns every bundled preset instance, and CarveConverter::addExtensions() bulk-registers any iterable of extensions:

$converter->addExtensions([
    ...FencedRenderExtension::presets(),
    new MathBlockExtension(),
]);

Note

presets() claims every preset fence word (mermaid, d2, dot, graphviz, wavedrom, abc, plantuml, puml, vega-lite, chart), so a literal code sample in one of those languages becomes a hydration element. Register only the presets whose client library you actually load if that matters.

Client rendering

Carve only emits the marker element (the class-tagged <pre>, or <div> with a child <script>); it never renders the diagram itself. Loading the client-side library and hydrating each emitted element is the host page's job: read the element's text (text mode) or its <script type="application/json"> (json mode) and hand it to the library. The library to load per built-in preset:

PresetFence word(s)ModeClient library
mermaid()mermaidtextmermaid.js
d2()d2textthe d2 WASM build (terrastruct/d2) or the d2 CLI server-side
graphviz()dot, graphviztextviz.js / d3-graphviz
wavedrom()wavedromtextwavedrom.js
abc()abctextabcjs
plantuml()plantuml, pumltext@plantuml/core (TeaVM build, runs in the browser without Java)
vegaLite()vega-litejsonvega-embed
chart()chartjsonChart.js

(MathBlockExtension shares the shape for ``` math fences; load KaTeX or MathJax.)

Note

Payload cost, PlantUML vs Mermaid. Both hydrate fully offline (load the file locally, no CDN). @plantuml/core is roughly ~2 MB gzipped - about double Mermaid's ~0.95 MB - because it bundles Graphviz (viz.js, ~0.6 MB gz) to lay out class / component / deployment diagrams; plantuml.js itself is ~1.4 MB gz. Sequence diagrams render to SVG without the layout engine, so a sequence-only page is lighter. Load PlantUML only on pages that use the UML types Mermaid cannot draw (use case, component, deployment, timing); prefer Mermaid where it suffices. (Sizes are the shipped browser builds, not npm's unpackedSize, which is dominated by source maps and inverts the comparison.)

Text-mode hydration reads textContent (Graphviz shown):

for (const el of document.querySelectorAll('pre.graphviz')) {
  el.replaceWith(viz.renderSVGElement(el.textContent));
}

JSON-mode hydration reads the child script (Chart.js shown):

for (const el of document.querySelectorAll('.chart')) {
  const cfg = JSON.parse(el.querySelector('script[type="application/json"]').textContent);
  new Chart(el.appendChild(document.createElement('canvas')), cfg);
}

Custom languages (no preset)

Any library that hydrates from element text or a JSON spec needs no new PHP - register the generic constructor with your own fence word:

// Text mode: a library that reads the element's textContent (e.g. nomnoml).
$converter->addExtension(new FencedRenderExtension(language: 'nomnoml'));
// -> <pre class="nomnoml">…escaped source…</pre>

// JSON mode: a spec-driven library with no preset (e.g. ECharts).
$converter->addExtension(new FencedRenderExtension(
    language: 'echarts',
    contentMode: FencedRenderExtension::MODE_JSON,
));
// -> <div class="echarts"><script type="application/json">{…}</script></div>

Then hydrate pre.nomnoml / .echarts on the client exactly as the presets above. Pass an array as language to claim several fence words (aliases), and set cssClass when the wrapper class should differ from the first word.

Note

Author attributes on the fence (a {#id .class key=val} block-attribute line above it) are copied onto the wrapper, but get the same treatment the core renderer applies to every element: always-on hardening (HtmlRenderer::sanitizeAttributes()) strips event handlers (on*), srcdoc, formaction and neutralizes dangerous URL / expression() values regardless of safe mode, then safe mode strips any additional names (e.g. style under strict). Values are HTML-escaped so a quote cannot break out. So a {onclick="..."} on the fence can never reach the output.

MathBlockExtension

Renders a fenced code block tagged math (a ``` math fence) as <div class="math display">\[ … \]</div>, the GFM-style block form of Carve's core $$ display math. The body is HTML-escaped (&, <, >) and wrapped in \[ … \] for a client-side math engine (KaTeX/MathJax). Non-math code blocks defer to the core renderer. HTML output only.

Constructor options:

  • language (string, default 'math') - language tag that marks a block.
$converter->addExtension(new MathBlockExtension());
$converter->addExtension(new MathBlockExtension(language: 'latex'));

Input / output:

``` math
x^2
```

renders as <div class="math display">\[x^2\]</div>. A preceding {#eq .big data-ref=x} block-attribute line merges onto the <div> - author classes after the math display base, then id and other attributes in source order:

{#eq .big data-ref=x}
``` math
x^2
```

renders as <div class="math display big" id="eq" data-ref="x">\[x^2\]</div>.

Note

Author attributes get the same treatment the core renderer applies to every element (and as FencedRenderExtension): always-on hardening (HtmlRenderer::sanitizeAttributes()) strips event handlers (on*), srcdoc, formaction and neutralizes dangerous URL / expression() values regardless of safe mode, then safe mode strips any additional names (e.g. style under strict). Values are HTML-escaped so a quote cannot break out. So a {onclick="…"} on a ``` math fence can never reach the output. This matches how core inline $`…` / display $$`…` math carry {...}.

SpoilerExtension

Hidden / blurred content revealed on interaction (inline + block). Implements the standard spoiler role from the spec's Extension Registry - no new syntax.

  • Inline :spoiler[text]<span class="spoiler">text</span>. Without the extension this stays the generic <span class="ext-spoiler">text</span>.
  • Block ::: spoiler "Title" → an HTML5 <details class="spoiler"> disclosure (native, keyboard- and screen-reader-accessible); a title-less block falls back to <summary>Spoiler</summary>. Without the extension this stays a plain <div class="spoiler">.
$converter->addExtension(new SpoilerExtension());

$converter->convert('Plot: :spoiler[the butler did it].');
// <p>Plot: <span class="spoiler">the butler did it</span>.</p>

$converter->convert("::: spoiler \"Ending\"\nEveryone lives.\n:::");
// <details class="spoiler">
//   <summary>Ending</summary>
//   <p>Everyone lives.</p>
// </details>

Carve emits only the marker; the blur + reveal is the host's CSS (like MermaidExtension). Author attributes merge onto the output element - the spoiler base class ahead of author classes, then id / key-values - with the always-on hardening (HtmlRenderer::sanitizeAttributes()) plus safe-mode name filtering and value escaping, so a {onclick="…"} can never reach the output.

Carve emits only the marker; the blur / collapse + reveal is the host's CSS/JS. Three host looks over the same markup (hover never reveals - it would spoil by accident; content stays in the DOM for screen readers):

  • inline :spoiler[text]<span class="spoiler"> styled as a blur that reveals on click;
  • a generic {.spoiler} block div → <div class="spoiler"> styled as a blurred panel that keeps its space, revealing on click;
  • ::: spoiler<details class="spoiler"> left as a native collapse (summary only, expands on click - no JS, fully keyboard/screen-reader accessible).

A .masked variant gives a credit-card / PIN look (every char a dot): :spoiler[1234]{.masked}.

/* Inline: blurred until clicked. */
span.spoiler { filter: blur(.3em); cursor: pointer; border-radius: 3px; padding: 0 .15em;
  background: rgba(127, 127, 127, .14); user-select: none; transition: filter .2s; }
span.spoiler.revealed { filter: none; background: transparent; user-select: text; }
/* Credit-card / PIN variant ({.masked}): every char a dot. */
span.spoiler.masked { filter: none; -webkit-text-security: disc; }
span.spoiler.masked.revealed { -webkit-text-security: none; }
/* Block as a blurred panel that keeps its space (a generic {.spoiler} div). */
div.spoiler { filter: blur(.4em); cursor: pointer; border-radius: 8px; padding: 10px 14px;
  border-left: 3px solid #e0af68; user-select: none; transition: filter .25s; }
div.spoiler.revealed { filter: none; cursor: auto; user-select: text; }
/* Block as a native collapse (::: spoiler): summary only until clicked. */
details.spoiler { border-left: 4px solid #e0af68; border-radius: 8px; padding: 6px 14px; }
details.spoiler > summary { color: #e0af68; cursor: pointer; list-style: none; }
details.spoiler > summary::before { content: "👁 "; }
details.spoiler > summary::after { content: " (click to reveal)"; font-weight: 400; }
details.spoiler[open] > summary::after { content: ""; }
// The two blur forms (inline span, block div) reveal on click / Enter / Space.
for (const el of document.querySelectorAll('span.spoiler, div.spoiler')) {
  el.tabIndex = 0
  el.setAttribute('role', 'button')
  el.setAttribute('aria-label', 'Spoiler, activate to reveal')
  const toggle = () => el.classList.toggle('revealed')
  el.addEventListener('click', toggle)
  el.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle() }
  })
}
// `::: spoiler` → <details> is a native disclosure - it collapses/expands on its own.

FrontmatterExtension (default)

Parses a leading frontmatter block. Active by default. The block opens with --- plus a format identifier (---yaml, ---toml, ---json, ...) to distinguish it from a thematic break; a bare --- opening falls back to the configured default format. The extension exposes the raw content - it does not interpret it, so use your preferred parser (symfony/yaml, etc.).

Constructor options:

  • defaultFormat (string, default 'yaml') - format assumed for a bare --- opening.
  • renderAsComment (bool, default false) - emit the frontmatter as an HTML comment instead of stripping it.
  • renderCallback (?Closure, default null) - fn(Frontmatter $fm): string for custom rendering.
// Default behavior is automatic. To read the parsed content, register your
// own instance and keep a reference:
$fm = new FrontmatterExtension();
$converter->addExtension($fm);

$converter->convert($source);

$frontmatter = $fm->getFrontmatter();
if ($frontmatter !== null) {
    echo $frontmatter->getFormat();  // 'yaml'
    echo $frontmatter->getContent(); // 'title: My Document...'
}

// With a parser:
$metadata = $fm->getParsedContent(
    fn ($content, $format) => $format === 'yaml' ? \Symfony\Component\Yaml\Yaml::parse($content) : null,
);

Input:

---yaml
title: My Document
author: John Doe
---

# Document content starts here

Inline and spans

Semantic span attributes

Compact semantic spans are portable core syntax and need no registration. SemanticSpanExtension remains as a deprecated no-op compatibility shim for applications that previously registered it.

Turns spans carrying semantic attributes into proper HTML5 elements: {kbd} -> <kbd>, {dfn} -> <dfn>, {abbr="..."} -> <abbr title="...">, {samp} -> <samp>, {var} -> <var>. The complete registry is abbr, time, code, mark, samp, var, kbd, cite, and dfn. Attributes can be combined, with dfn wrapping inner elements. Values on abbr, dfn, and time map to title, title, and datetime; other attributes remain on one hardened outer <span>.

$converter->convert('[Ctrl+C]{kbd}');
// <p><kbd>Ctrl+C</kbd></p>

$converter->convert('[HTML]{abbr="HyperText Markup Language"}');
// <p><abbr title="HyperText Markup Language">HTML</abbr></p>

$converter->convert('[CSS]{dfn abbr="Cascading Style Sheets"}');
// <p><dfn><abbr title="Cascading Style Sheets">CSS</abbr></dfn></p>

For automatic abbreviation expansion (define once, apply everywhere) use the built-in *[HTML]: HyperText Markup Language definition syntax instead.

ColorSwatchExtension

Renders an inline color chip next to a color value. Claims the reserved inline color role from the spec's Extension Registry - no new syntax. The value is validated as a safe CSS color before any chip is emitted; anything that is not a recognized color defers to the generic <span class="ext-color">value</span> fallback, so a bareword like :color[banana] or an invalid token like :color[nope!] produces no chip.

Accepted values:

  • Hex - #rgb, #rgba, #rrggbb, #rrggbbaa (e.g. #36c, #ff8800).
  • Functional - rgb() / rgba() / hsl() / hsla() (must contain a digit; rgb() with no numbers is rejected).
  • Named - any CSS named color plus transparent / currentcolor, case-insensitive (e.g. rebeccapurple, DarkSlateGray).
$converter->addExtension(new ColorSwatchExtension());

$converter->convert(':color[#ff8800]');
// <p><span class="swatch"><span class="swatch-chip" style="background-color:#ff8800"></span> #ff8800</span></p>

$converter->convert(':color[rebeccapurple]');
// <p><span class="swatch"><span class="swatch-chip" style="background-color:rebeccapurple"></span> rebeccapurple</span></p>

// Not a color: defers to the generic fallback (no chip).
$converter->convert(':color[banana]');
// <p><span class="ext-color">banana</span></p>

Constructor options:

  • position (string, default 'before') - chip placement: before the value, after it, or none (chip only; the value becomes the element title).
  • shape (string, default 'square') - square / round (filled dot) / ring (the color is the border, not the fill).
  • tint (bool, default false) - paint a faint tint of the color behind the whole swatch (via CSS color-mix; decorative, degrades where unsupported).
  • reveal (bool, default false) - collapse the value text and reveal it on hover / keyboard focus (pure-CSS via the swatch-reveal class; the value stays in the DOM for assistive tech). Ignored when position is none.
// chip after the value, hollow ring, faint tint behind:
new ColorSwatchExtension(position: 'after', shape: 'ring', tint: true);

Author attributes merge onto the output element - the swatch base class ahead of author classes, then id / key-values - with the always-on hardening (HtmlRenderer::sanitizeAttributes()) plus safe-mode name filtering and value escaping, so a {onclick="…"} can never reach the output. An author-supplied style or title wins over the extension's own.

$converter->convert(':color[#fff]{#brand .accent}');
// <p><span class="swatch accent" id="brand"><span class="swatch-chip" style="background-color:#fff"></span> #fff</span></p>

Carve emits only the markup; sizing the chip is the host's CSS (an empty <span> has no dimensions, so without this the chip is invisible):

.swatch { white-space: nowrap; }
.swatch-chip { display: inline-block; width: .85em; height: .85em; margin-right: .15em;
  border-radius: 3px; vertical-align: -.08em; border: 1px solid rgba(0, 0, 0, .25);
  box-sizing: border-box; }
.swatch-chip-round { border-radius: 50%; }
.swatch-chip-ring { background: transparent !important; border-width: 2px; }
/* position: 'after' puts the chip last; tweak the margin side if you prefer. */
.swatch-tint { padding: 0 .25em; border-radius: 4px; }
/* reveal: true - value hidden until hover / focus, kept in the DOM for AT. */
.swatch-reveal .swatch-val { visibility: hidden; }
.swatch-reveal:hover .swatch-val, .swatch-reveal:focus .swatch-val { visibility: visible; }

InlineFootnotesExtension

Converts a span with the .fn class into an inline footnote, so footnote content can be written inline rather than in a separate definition block. Inline footnotes share the same numbering sequence as regular footnotes and appear together in the footnotes section. The content supports full inline formatting. HTML output only; for other renderers use InlineFootnotesToParenthesesTransform.

Constructor options:

  • cssClass (string, default 'fn') - the class that marks a span as an inline footnote.
$converter->addExtension(new InlineFootnotesExtension());

$converter->convert('Some text[An inline footnote]{.fn} continues.');

SmartQuotesExtension

Configures locale-specific typographic quote characters. By default the parser uses English quotes; this extension switches them per locale (German low/high, French guillemets, etc.) while keeping apostrophes as U+2019 regardless of locale.

Constructor options:

  • locale (?string, default null -> 'en') - locale code such as 'de', 'fr', 'de-CH'. Built-in locales include en, de, de-CH, fr, pl, ru, ja, zh, sv, da, fi, cs, hu, it, es, pt, nl, nb, nn, uk.
  • openDoubleQuote / closeDoubleQuote / openSingleQuote / closeSingleQuote (?string, default null) - explicit overrides that take precedence over the locale.

How smart typography is represented

Smart typography is not a substitution into the text: each transform becomes a SmartPunctuation inline node carrying both the resolved kind (ellipsis, em_dash, rightwards_arrow, left_double_quote, …) and the author's source run (..., ---, ->, ").

Presentation renderers (HTML, Markdown, plain text, ANSI) resolve the kind to a glyph, so their output is what it has always been. The Carve renderer emits the source run instead, which is what makes fmt reproduce the document rather than normalize it:

input      He said "hello" and it's fine... a--b
--html     <p>He said “hello” and it’s fine… a–b</p>
--carve    He said "hello" and it's fine... a--b

An escaped form stays literal in both directions: a\.\.\.b renders the three dots and formats back to a\.\.\.b.

Quote glyphs are locale-dependent, so a quote node carries the character the smart-quotes configuration resolved during parsing; every other kind resolves through a shared table (SmartPunctuation::GLYPHS).

$converter->addExtension(new SmartQuotesExtension(locale: 'de'));

// Or explicit characters:
$converter->addExtension(new SmartQuotesExtension(
    openDoubleQuote: "\u{00AB}",
    closeDoubleQuote: "\u{00BB}",
));

Lists

PlusBulletExtension

By default Carve does not treat + as a bullet marker; it is reserved as the list-continuation marker. PlusBulletExtension re-enables + alongside - and *, with one deliberate difference: a + is only a bullet when followed by a space and non-empty content. A content-less + (bare, or trailing whitespace only) stays the continuation marker, so the two never collide. + + follows the same first-block-item syntax as - + / * + (the trailing + is the first-block sentinel), not a literal + item.

use MarkupCarve\Carve\CarveConverter;
use MarkupCarve\Carve\Extension\PlusBulletExtension;

$converter = new CarveConverter();
$converter->addExtension(new PlusBulletExtension());

$converter->convert("+ Apple\n+ Banana\n"); // <ul><li>Apple</li><li>Banana</li></ul>
$converter->convert("+ [ ] todo\n");         // task list item
$converter->convert("+\n");                  // <p>+</p> - still the continuation marker

Glossary and index

Two Tier-3 extensions (off by default, never corpus-pinned) for long-form and book-style documents. Both reuse existing syntax - the definition list and the :name[…] inline form - and find their containers nested anywhere (blockquote, list, div). See the cross-impl contract in the spec's docs/extensions.md §7-§8.

GlossaryExtension

A ::: glossary definition list declares terms; :term[word] links a use to its <dt id="gloss-{slug}">. The slug is the lowercased heading-id slug of the term text, so :term[HTTP] and a :: HTTP entry meet at gloss-http with no explicit key. The block renders <dl class="glossary"> (each list in source order, any intro/interstitial prose preserved in place); an undefined term degrades to <span class="term">word</span>, and with the extension off :term[word] is the generic <span class="ext-term">word</span>.

$converter->addExtension(new GlossaryExtension());

IndexExtension

:index[term] is an invisible marker - it emits an empty <span id="idx-{slug}-{n}" class="index-term"></span> anchor target (a span, so it never nests inside a link). A ::: index block collects every body marker into a <ul class="index"> sorted by Unicode codepoint, each entry back-linking to all its occurrences. Markers in deferred content (footnote definitions) render inert, and with no markers ::: index stays a plain <div class="index">.

$converter->addExtension(new IndexExtension());

Headings

HeadingNumbersExtension

Auto-numbers sections and rewrites auto-filled </#id> cross-references (issue #198). Each numbered heading gains a <span class="section-number">1.2</span> inside its <h*> (gap-free dotted counter; the id stays on the <section>), and a </#id> cross-reference to a numbered heading renders Section 1.2 - Title. Skips blockquote-quoted and {.unnumbered} headings; ordinary [text](#id) links and implicit [label][] references keep their text. Options: minLevel (default 1; set 2 when # is the doc title), label (default Section), crossref (number | number-title | title). Opt-in, Tier-3, not corpus-pinned.

$converter->addExtension(new HeadingNumbersExtension(minLevel: 2));

Output post-processing

DefaultAttributesExtension

Adds default attributes to elements by type (CSS classes, lazy loading, etc.). Defaults are only applied when the element does not already have that attribute; class values are merged rather than overwritten. No-op when given an empty map.

Constructor options:

  • defaults (array<string, array<string, string>>, default []) - map of element type (snake_case) to attributes. Supported types include block: paragraph, heading, code_block, block_quote, list, list_item, table, table_cell, div, thematic_break; inline: link, image, emphasis, strong, code, span, subscript, superscript, footnote, footnote_ref.
$converter->addExtension(new DefaultAttributesExtension([
    'image' => ['loading' => 'lazy', 'decoding' => 'async'],
    'table' => ['class' => 'table table-striped'],
    'code_block' => ['class' => 'highlight'],
]));

TabNormalizeExtension

Expands literal tabs in code content to a fixed number of spaces at render time. Carve preserves literal tabs by default (tab display is a CSS tab-size concern); add this for fixed-width output without CSS (email, RSS, plain HTML). Flat replacement (no elastic tab stops); only code content is touched. HTML output only.

Constructor options:

  • width (int, default 2) - spaces per tab.
$converter->addExtension(new TabNormalizeExtension());          // 2 spaces
$converter->addExtension(new TabNormalizeExtension(width: 4));  // 4 spaces

Extension Matchers

Carve-PHP supports parse-stage extension matchers alongside render hooks and document transforms. Matchers are tried only where core syntax declines, so core parsing always wins first.

use MarkupCarve\Carve\CarveConverter;
use MarkupCarve\Carve\Node\Inline\Text;
use MarkupCarve\Carve\Parser\MatcherContext;

$converter = new CarveConverter();

$converter->getParser()->getInlineParser()->addInlineMatcher(
    function (string $text, int $pos, MatcherContext $ctx): ?array {
        if (!preg_match('/\G\{\{([a-z]+)\}\}/', $text, $m, 0, $pos)) {
            return null;
        }

        return ['node' => new Text('VAR:' . $m[1]), 'end' => $pos + strlen($m[0])];
    },
    priority: 0,
    triggerChars: '{', // only run this matcher at a `{`
);

MatcherContext exposes definition tables (getReference(), hasFootnote(), getAbbreviation()) and recursive parse helpers (parseInlines(), parseBlocks()). Matchers run by descending priority, then registration order. addInlinePattern() and addBlockPattern() remain available as regex sugar over the same matcher contract.

For a raw-closure addInlineMatcher(), pass triggerChars (the literal first bytes the matcher can ever fire on, e.g. '{' above) so the parser only invokes it at those positions. Without it, the matcher runs at every scan position and disables the per-character fast path for the whole document — a measurable slowdown on long inputs. A matcher registered through addInlinePattern() derives its trigger bytes from the pattern automatically.

The normative extension contract lives in carve/docs/extensions.md. Extensions bundled with this package (such as PlusBulletExtension) are documented above.