BaseTag & Interfaces
This page defines the contract for custom tags in Docara core and documents the default behaviors provided by BaseTag.
Start here before implementing your own tag classes.
The contract: CustomTagInterface
Located in core at src/Interface/CustomTagInterface.php (Simai\Docara\Interface\CustomTagInterface).
interface CustomTagInterface
{
public function type(): string;
/** Regex for the opening line. Must expose an `attrs` named group if attributes are supported. */
public function openRegex(): string;
/** Regex for the closing line. */
public function closeRegex(): string;
/** Wrapper element for default rendering (e.g., 'div', 'section'). */
public function htmlTag(): string;
/** Baseline attributes merged with inline attributes on the open line. */
public function baseAttrs(): array;
/** Allow nesting of the same tag type inside itself. */
public function allowNestingSame(): bool;
/** Optional filter to normalize/whitelist attributes. Signature: fn(array $attrs, array $meta): array */
public function attrsFilter(): ?callable;
/** Optional renderer to fully control output. Signature: fn(CustomTagNode $node, ChildNodeRendererInterface $childRenderer): string */
public function renderer(): ?callable;
}
You normally extend
BaseTagwhich implements this interface with safe defaults and proven regexes.
Default implementation: BaseTag
Located in core at src/CustomTags/BaseTag.php (Simai\Docara\CustomTags\BaseTag).
abstract class BaseTag implements CustomTagInterface
{
abstract public function type(): string;
public function openRegex(): string {
return '/^\s*!' . preg_quote($this->type(), '/') . '(?:\s+(?<attrs>.+))?$/u';
}
public function closeRegex(): string {
return '/^\s*!end' . preg_quote($this->type(), '/') . '\s*$/u';
}
public function htmlTag(): string { return 'div'; }
public function baseAttrs(): array { return []; }
public function allowNestingSame(): bool { return true; }
public function attrsFilter(): ?callable { return null; }
public function renderer(): ?callable { return null; }
}
Why these defaults?
- Regexes are anchored at the start of the line and tolerate leading whitespace. The
openRegex()exposes a named captureattrsso the parser can extract inline attributes if present. htmlTag()defaults todiv, which is the safest block wrapper.allowNestingSame()istrueto keep authoring flexible; you can disable it where it makes semantic sense.attrsFilter()andrenderer()are opt-in extension points: use them only when you need additional control.
Method-by-method guidance
type(): string
- Unique, short, lowercase by convention (e.g.,
note,example,video). - Appears in Markdown as
!<type>and!end<type>.
openRegex() / closeRegex()
- If you override, preserve the semantics:
- Anchor with
^to avoid accidental matches mid-line. - Keep the named group
(?<attrs>...)for the open line if you want inline attributes. - Use the Unicode
umodifier so\sand character classes handle non-ASCII whitespace.
- Anchor with
- Example (customizing to allow an alias):
public function openRegex(): string {
$t = preg_quote($this->type(), '/');
return '/^\s*!(?:' . $t . '|ex)\b(?:\s+(?<attrs>.+))?$/u';
}
public function closeRegex(): string {
$t = preg_quote($this->type(), '/');
return '/^\s*!end(?:' . $t . '|ex)\b\s*$/u';
}
Changing regexes is advanced: ensure you don't break the parser's ability to find boundaries or capture
attrs.
htmlTag(): string
- Return the wrapper element name, e.g.,
'section','aside','figure'. - Keep it a valid HTML tag name; the renderer doesn't validate element names.
baseAttrs(): array
- Provide minimal semantic defaults, most commonly base CSS classes.
- Attributes merge order is:
baseAttrs()-> inline attributes from Markdown -> renderer-time adjustments. - Classes are concatenated and de-duplicated; scalars (like
id) are overridden by later sources.
allowNestingSame(): bool
- Return
falseto disallow!noteinside!note(the block parser will treat inner opens as text until the outer close).
attrsFilter(): ?callable
- Signature:
fn(array $attrs, array $meta): array. $metacontains parser metadata from the opening line, including:openMatch- the full regex match array foropenRegex()(e.g., named groups)attrStr- the raw attribute substring after!type
- Good for whitelisting, mapping semantic options into classes, or deriving attrs from captured groups.
Example: map theme to classes, strip unknown keys, and use a named capture variant from openRegex() if present:
public function attrsFilter(): ?callable
{
return function (array $attrs, array $meta): array {
$out = [];
$allowed = ['id', 'class', 'data-x', 'theme'];
foreach ($attrs as $k => $v) if (in_array($k, $allowed, true)) $out[$k] = $v;
// optional: derive from open regex capture
$variant = $meta['openMatch']['variant'] ?? null; // requires a (?<variant>...) group in openRegex
if ($variant) {
$out['class'] = trim(($out['class'] ?? '') . ' variant-' . $variant);
}
if (isset($out['theme'])) {
$map = ['info' => 'is-info', 'warning' => 'is-warn'];
$cls = $map[$out['theme']] ?? null;
unset($out['theme']);
if ($cls) $out['class'] = trim(($out['class'] ?? '') . ' ' . $cls);
}
return $out;
};
}
renderer(): ?callable
- Signature:
fn(CustomTagNode $node, ChildNodeRendererInterface $childRenderer): string. - Use when the default
<htmlTag ...>innerHtml</htmlTag>is not enough. - Render inner HTML via
$childRenderer->renderNodes($node->children()); read attributes from$node->getAttrs(). - Example: render as
<figure>with an optional caption attribute:
public function renderer(): ?callable
{
return function (CustomTagNode $node, ChildNodeRendererInterface $childRenderer): string {
$attrs = $node->getAttrs();
$innerHtml = $childRenderer->renderNodes($node->children());
$classes = htmlspecialchars($attrs['class'] ?? '', ENT_QUOTES, 'UTF-8');
$caption = htmlspecialchars($attrs['caption'] ?? '', ENT_QUOTES, 'UTF-8');
$fig = '<figure class="' . $classes . '">' . $innerHtml;
if ($caption !== '') $fig .= '<figcaption>' . $caption . '</figcaption>';
return $fig . '</figure>';
};
}
Lifecycle of a tag (end-to-end)
- Open/Close detection:
UniversalBlockParsermatchesopenRegex()/closeRegex()for the tag'stype(). - Inner parse: everything between markers is parsed as Markdown into child nodes.
- Attributes: the open line's
attrssegment is parsed byAttrs, normalized (Unicode spaces/quotes), and merged withbaseAttrs(). - Filtering: if
attrsFilter()exists, it is called asfn($attrs, $meta)where$metaincludesopenMatchandattrStr. - Rendering: if
renderer()exists, it is called with the node and child renderer; otherwise the default<htmlTag ...attrs>innerHtml</htmlTag>is emitted.
Best practices
- Keep
type()short and stable; changing it is a breaking authoring change. - Prefer
baseAttrs()+ author classes over hardcoding heavy styling. - Use
attrsFilter()to normalize author input; avoid doing this inrenderer(). - Escape everything you output in a custom
renderer(). - Write a small Markdown fixture per tag; it doubles as documentation.
Anti-patterns
- Overriding
openRegex()without keeping theattrscapture. - Returning invalid element names from
htmlTag(). - Packing complex logic into
renderer()that belongs in CSS orattrsFilter().
Minimal tag template
Use this as a starting point for new tags.
namespace App\Helpers\CustomTags;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use Simai\Docara\CustomTags\BaseTag;
use Simai\Docara\CustomTags\CustomTagNode;
final class MyTag extends BaseTag
{
public function type(): string { return 'mytag'; }
public function baseAttrs(): array { return ['class' => 'mytag']; }
// Optional normalization
public function attrsFilter(): ?callable
{
return fn(array $a) => $a; // no-op by default
}
// Optional custom render
// public function renderer(): ?callable
// {
// return fn(CustomTagNode $node, ChildNodeRendererInterface $r): string
// => $r->renderNodes($node->children());
// }
}
Testing checklist
- Open/close markers recognized; nested same-type behavior matches
allowNestingSame(). - Attributes: quoted/unquoted,
.class,#idare parsed and merged; classes de-duplicated. attrsFilter()behaves as intended on valid/invalid inputs.- Default wrapper vs custom
renderer()both produce valid, escaped HTML.
FAQ
Q: Can I support boolean attributes (flag style)?
A: Prefer explicit key="true" or map via attrsFilter() (e.g., treat presence of flag key as true).
Q: How do I provide multiple aliases for one tag?
A: Override openRegex()/closeRegex() carefully (see example), but keep the attrs capture and start anchors.
Q: How do I prevent specific attributes?
A: Implement attrsFilter() and whitelist keys; drop anything else.