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 -
@mentionsand#tagsare 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:
- the extension's
renderStaticHtml(), if it claims the node; - else the extension's ordinary renderer (correct for extensions that are already static - list-table, citations, heading-permalinks - which need no static path);
- 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:
| Extension | Static (renderStaticHtml) output |
|---|---|
TabsExtension | each panel as a <section class="tabs-panel"> headed by <h3 class="tabs-label"> carrying the tab label (no radio inputs, no tab buttons) |
CodeGroupExtension | each code block as a <section class="code-group-panel"> headed by <h3 class="code-group-label"> carrying its [label] |
MathBlockExtension | the 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"> |
ImgFenceExtension | what 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"> |
SpoilerExtension | block: 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.
Links and references
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. Whenmailtois 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, defaultfalse) - appendnofollowto therelvalue.
$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, defaultfalse).
$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 tonew 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, default1) - 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, defaultfalse) - adds apermalink-hoverclass to the wrapper for CSS-driven hover reveal.copyToClipboard(bool, defaultfalse) - adds adata-permalink-copyattribute 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, default1) /maxLevel(int, default6) - 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 whenpositionis set.collapsible(bool, defaultfalse) - 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 whencollapsibleis true.open(bool, defaultfalse) - render the disclosure expanded by default, used only whencollapsibleis 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, defaulttrue) - 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, defaultfalse) -trueuses 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=Npromotes the firstNrows to<thead>with<th>cells.header-cols=Npromotes the firstNcells 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=Nstill 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 ofNis the cell plusN - 1^cells in the following rows. - A cell whose sole content is a lone
<merges with the cell to the left (colspan). A colspan ofKis the cell plusK - 1<cells (socolspan=3isTotal,<,<).
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, defaultnull) - the accessible name for the tab set as a whole. Left unset the string comes from the converter'slabelsmap undertabsGroup(defaultTabs).
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): stringto integrate a syntax highlighter.groupLabel(?string, defaultnull) - the accessible name for the code group as a whole. Left unset the string comes from the converter'slabelsmap undercodeGroup(defaultCode examples).mode(string, default'css') -'css'or'aria', the same two modesTabsExtensioncarries. 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 firstlanguageword) - class on the element.tag(string, default'div'for json mode else'pre') - wrapper element.contentMode(string, defaultFencedRenderExtension::MODE_TEXT) -MODE_TEXTorMODE_JSON(see below).wrapInFigure(bool, defaultfalse) - 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 fromtextContent):// 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:
| Preset | Fence word(s) | Mode | Client library |
|---|---|---|---|
mermaid() | mermaid | text | mermaid.js |
d2() | d2 | text | the d2 WASM build (terrastruct/d2) or the d2 CLI server-side |
graphviz() | dot, graphviz | text | viz.js / d3-graphviz |
wavedrom() | wavedrom | text | wavedrom.js |
abc() | abc | text | abcjs |
plantuml() | plantuml, puml | text | @plantuml/core (TeaVM build, runs in the browser without Java) |
vegaLite() | vega-lite | json | vega-embed |
chart() | chart | json | Chart.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, defaultfalse) - emit the frontmatter as an HTML comment instead of stripping it.renderCallback(?Closure, default null) -fn(Frontmatter $fm): stringfor 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:beforethe value,afterit, ornone(chip only; the value becomes the elementtitle).shape(string, default'square') -square/round(filled dot) /ring(the color is the border, not the fill).tint(bool, defaultfalse) - paint a faint tint of the color behind the whole swatch (via CSScolor-mix; decorative, degrades where unsupported).reveal(bool, defaultfalse) - collapse the value text and reveal it on hover / keyboard focus (pure-CSS via theswatch-revealclass; the value stays in the DOM for assistive tech). Ignored whenpositionisnone.
// 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, default2) - 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.