Custom Plugins#
Create plugins for Wenmode(..., plugins=[...]) that package syntax rules,
nodes, renderer handlers, and setup options.
Create a plugin when you want to add syntax or output behavior that is not part
of the CommonMark, GFM, mdast directive, or built-in plugin surface. A plugin is
the unit your application installs with Wenmode(..., plugins=[plugin]).
Before writing a custom plugin, check whether the feature can be expressed as:
a preset or configured rule list, when no new node type is needed,
a built-in plugin from Plugins, when Wenmode already provides the syntax,
a directive renderer, when the syntax should stay in the mdast directive family.
Plugins usually keep these pieces together:
custom node classes,
parser rules and root transforms,
renderer handlers for supported output formats,
a
setup(wen, /)function.
Plugin Shape#
A plugin can be a module or an object. During construction, Wenmode calls
setup(wen, /) on each plugin in plugins=[...]. Use configure() to return a
configured plugin object when a plugin needs options.
For simple delimiter or fenced-block syntax, import BlockFenced,
InlineDelimited, or InlineLiteral from wenmode.plugins. The underlying
implementation remains internal; wenmode.plugins is the supported import path
for custom plugins.
from wenmode import Wenmode
from wenmode.rules import Emphasis
class EmphasisOnlyPlugin:
def setup(self, wen: Wenmode, /) -> None:
wen.register_rule(Emphasis)
wen = Wenmode([], plugins=[EmphasisOnlyPlugin()])
assert wen.render('*emphasis*') == '<p><em>emphasis</em></p>\n'
Module plugins expose setup():
from wenmode import Wenmode
def setup(wen: Wenmode, /) -> None:
wen.register_rules(rules)
wen.register_renderer_handlers(handlers)
Applications import the module and pass it to plugins:
from wenmode import Wenmode
from my_project.wenmode_plugins import plus_mark
wen = Wenmode(plugins=[plus_mark])
Complete Inline Plugin#
This plugin parses ++marked++ into a custom plusMark node and teaches the
HTML renderer how to serialize it.
from dataclasses import dataclass
from wenmode import Wenmode
from wenmode.nodes import Parent
from wenmode.rules import InlineCandidate, InlineRule
@dataclass
class PlusMarkNode(Parent):
type: str = 'plusMark'
class PlusMarkRule(InlineRule):
name = 'plus_mark'
opener = '+'
def parse(self, parser, text, candidate, state):
start = candidate.start
if not text.startswith('++', start):
return None, start
value_start = start + 2
close = text.find('++', value_start)
if close == -1:
return None, start
children = parser.parse_inlines(text[value_start:close], state)
return PlusMarkNode(children=children), close + 2
nodes = [PlusMarkNode]
rules = [PlusMarkRule]
handlers = {
'html': {
PlusMarkNode.type: lambda renderer, node, context: (
f'<mark>{renderer.render_children(node.children, context)}</mark>'
)
}
}
class PlusMarkPlugin:
nodes = nodes
rules = rules
handlers = handlers
def setup(self, wen: Wenmode, /) -> None:
wen.register_rules(self.rules)
wen.register_renderer_handlers(self.handlers)
wen = Wenmode(plugins=[PlusMarkPlugin()])
expected = '''
<p><mark>very <em>important</em></mark></p>
'''
assert wen.render('++very *important*++') == expected.lstrip()
The custom InlineRule creates the parser node. If no renderer handler matches
the node type, BaseRenderer falls back to child nodes or a literal value.
Expose the nodes list when callers may restore serialized AST data with
wenmode.ast.from_ast().
Renderer Handlers#
Plugins can expose renderer handlers separately from parser rules. The mapping is keyed by renderer name; only handlers for the current renderer are installed.
handlers = {
'html': {'plusMark': render_plus_mark_html},
'markdown': {'plusMark': render_plus_mark_markdown},
'rst': {'plusMark': render_plus_mark_rst},
}
Call register_renderer_handlers() inside setup():
def setup(wen: Wenmode, /) -> None:
wen.register_rules([PlusMarkRule])
wen.register_renderer_handlers(handlers)
Use stable node type values. Renderer handlers are selected by node.type,
not by the Python class name.
For document-level prefixes or suffixes, prefer the root:pre and root:post
pseudo handlers over replacing the root handler. They preserve built-in root
behavior such as footnotes or deferred image definitions.
Root hooks require a complete parsed Root, so registering one blocks
Wenmode.stream(). Plugins that need incremental output should avoid root
hooks until a streaming hook API exists.
handlers = {
'markdown': {'root:pre': render_document_metadata},
}
Setup Options#
Expose a configure() helper when callers need to configure part of a plugin.
from dataclasses import dataclass
@dataclass(frozen=True)
class MyPlugin:
inline: bool = True
block: bool = True
def setup(self, wen: Wenmode, /) -> None:
selected_rules = []
if self.inline:
selected_rules.append(MyInlineRule)
if self.block:
selected_rules.append(MyBlockRule)
wen.register_rules(selected_rules)
wen.register_renderer_handlers(handlers)
def configure(*, inline: bool = True, block: bool = True) -> MyPlugin:
return MyPlugin(inline=inline, block=block)
Validate option values inside configure() or the configured plugin’s
setup() when the plugin needs stricter behavior.
For built-in-style configurable plugins, keep options keyword-only, return an
immutable @dataclass(frozen=True) plugin object from configure(), and keep
setup(wen, /) free of extra parameters. That keeps module plugins and
configured plugin objects interchangeable in Wenmode(..., plugins=[...]).
Rule Types Inside Plugins#
Rules are implementation details of a plugin. Use the rule type that matches the syntax you are adding:
Rule type |
Use it for |
|---|---|
|
inline spans such as |
|
standalone block starts such as fenced blocks |
|
paragraph continuations such as definition-list items |
|
local in-place node updates that can run during streaming |
|
document-wide state or tree rewrites |
Every rule has a stable name. Parser rule names are used as dictionary keys,
and block rule names are used as regex group names when the parser compiles
block openers, so use snake_case identifier-style names.
Rules also have an order class attribute. Block and inline rules default to
order = 100; lower values run earlier when syntax overlaps.
class MyRule(InlineRule):
name = 'my_rule'
pattern = r'!!'
opener = '!'
order = 90
Wenmode.register_rule() and Wenmode.register_rules() accept rule classes or
configured rule instances. Classes are instantiated automatically. Instances are
useful when the rule itself has options.
For stateless custom rules, prefer defining name, opener, and, when needed,
pattern as class attributes. opener is a single-character dispatch hint, or
a tuple of single-character dispatch hints; validate longer delimiters inside
match_candidate() or parse(). Keep __init__() only when the rule needs
caller-provided configuration.
Parsing Nested Content#
When a custom rule contains nested Markdown content, call parser helpers so the nested content uses the same rule set. Wenmode automatically assigns the outer node the complete range consumed by the rule.
Parse an inline label or body directly when nested child positions are not needed:
children = parser.parse_inlines(text[value_start:value_end], state)
When nested children need positions mapped to the original document, pass the source range explicitly:
value_start = start + 2
value_end = text.find('++', value_start)
children = parser.parse_inlines(
text[value_start:value_end],
state,
source=parser.inline_source(text, state, value_start, value_end),
)
Block rules can call parser.parse_blocks() directly for normal nested
parsing. When nested children need original source positions, collect a source
map before calling it:
source = state.source.collect()
def collect_line(line: str) -> str:
source.add(state.index, 0, line)
return line
lines = state.consume_until(is_closer, collect_line)
children = parser.parse_blocks(
''.join(lines),
parent_state=state,
source=source.map(),
)
parser.parse_blocks() enforces Parser.max_container_depth for nested block
content. At the limit, it returns shallow blank-separated paragraphs that
preserve source text and positions. Do not bypass this helper with a custom
recursive parser.
If a rule decides not to handle a match, return None without consuming input.
For inline rules, return (None, start). The parser will continue with the
normal fallback behavior.
When a BlockRule or ContinueRule returns a node, it must advance state
past the accepted input. Returning a node without advancing state raises
RuntimeError. A ContinueRule that returns None must leave state
unchanged; a block rule may advance state and return None when it consumes
input without producing a node.
Source Positions#
Most rules should not set the outer node position. If positions=True and the
returned node has position=None, the parser fills it with the complete source
range consumed by the rule. Positions store 0-based offsets. If a rule sets a
position itself, the parser will not overwrite it.
For a simple inline rule, this is enough:
from dataclasses import dataclass
from wenmode.nodes import Node
@dataclass
class MentionNode(Node):
name: str = ''
type: str = 'mention'
class MentionRule(InlineRule):
name = 'mention'
pattern = r'@[A-Za-z][A-Za-z0-9_]*'
opener = '@'
def parse(
self,
parser: Parser,
text: str,
candidate: InlineCandidate,
state: BlockState,
) -> tuple[Node | None, int]:
match = candidate.match
assert match is not None
return MentionNode(name=match.group(0)[1:]), match.end()
The parser will assign MentionNode.position to the @name span. Do not set it
manually unless the node should point somewhere else.
Set positions manually only when a node or child node should point to a smaller range than the complete syntax. This usually happens when a transform splits an existing text node:
from wenmode.nodes import Position, Text
if node.position is not None:
child = Text(
value=node.value[start:end],
position=Position(
start=node.position.start + start,
end=node.position.start + end,
),
)
When nested Markdown children need precise positions, prefer a source map over
manual child positions. parser.inline_source() maps child nodes to nested
content while the parent keeps the full consumed range.
Root.to_ast() converts offsets to unist-style line and column fields.
Standalone nodes, including Parser.parse_iter() results, serialize positions
with offsets only.
Plugin State And Transforms#
Parser, rule, plugin, and transform instances should not store per-parse
mutable state. Use BlockState.store with a StateKey when a plugin needs
shared state for one parse.
from wenmode.state import StateKey
TERMS = StateKey('my_package.terms', lambda: {})
Rules can declare required_rules; the parser automatically registers missing
required rules when it rebuilds the rule set.
Use defer_inlines = True only when inline parsing needs document-wide state
collected by a transform, such as reference-style links or abbreviation
definitions. A transform that needs to run after deferred inline parsing should
schedule work with state.defer_inline_callback(). Rule sets with deferred
inline parsing cannot be used with streaming output.
If a plugin is intended for streaming output, test it through
Wenmode(streaming, plugins=[plugin]).stream(...) or through an equivalent
custom streaming rule list.
Testing Plugins#
Test the plugin through Wenmode(..., plugins=[...]), because that is the
recommended API applications will call.
from wenmode import HTMLRenderer, Wenmode
def render(markdown: str) -> str:
return Wenmode(renderer=HTMLRenderer(), plugins=[PlusMarkPlugin()]).render(markdown)
def test_plus_mark() -> None:
assert render('++a *b*++') == '<p><mark>a <em>b</em></mark></p>\n'
assert render('++open') == '<p>++open</p>\n'
Useful cases include:
recognized syntax renders as expected,
unmatched or incomplete syntax stays as text,
nested inline or block parsing works,
renderer handlers are registered for each supported output format,
setup options change behavior as documented,
per-parse state does not leak between renders.