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(wenmode, **options)function.
Plugin Shape¶
A plugin can be a module or an object. During construction, Wenmode looks for
a callable setup() function on each plugin in plugins=[...] and passes the
Wenmode instance. Use Wenmode.use(plugin, **options) when a plugin needs
setup options.
from typing import Any
from wenmode import Wenmode
from wenmode.rules import Emphasis
class EmphasisOnlyPlugin:
def setup(self, wenmode: Wenmode, **options: Any) -> None:
wenmode.register_rule(Emphasis)
wenmode = Wenmode([], plugins=[EmphasisOnlyPlugin()])
assert wenmode.render('*emphasis*') == '<p><em>emphasis</em></p>\n'
Module plugins use the same shape. This is the pattern used by built-in plugins:
from typing import Any
from wenmode import Wenmode
def setup(wenmode: Wenmode, **options: Any) -> None:
wenmode.register_rules(rules)
wenmode.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
wenmode = 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.
import re
from typing import Any
from wenmode import HTMLRenderer, Wenmode
from wenmode.nodes import Node, Parent
from wenmode.parser import Parser
from wenmode.renderers import RenderContext
from wenmode.rules import InlineRule, Rule
from wenmode.state import BlockState
class PlusMarkNode(Parent):
def __init__(self, children: list[Node]) -> None:
super().__init__('plusMark', children=children)
class PlusMarkRule(InlineRule):
name = 'plus_mark'
pattern = r'\+\+'
trigger_chars = '+'
def parse(
self,
parser: Parser,
text: str,
match: re.Match[str],
state: BlockState,
) -> tuple[Node | None, int]:
end = text.find('++', match.end())
if end == -1:
return None, match.start()
value_start = match.end()
children = parser.parse_inlines(
text[value_start:end],
state,
source=parser.inline_source(text, state, value_start, end),
)
return PlusMarkNode(children=children), end + 2
def render_plus_mark(renderer: HTMLRenderer, node: PlusMarkNode, context: RenderContext) -> str:
return f'<mark>{renderer.render_children(node.children, context)}</mark>'
rules: list[type[Rule] | Rule] = [PlusMarkRule]
handlers = {'html': {'plusMark': render_plus_mark}}
class PlusMarkPlugin:
def setup(self, wenmode: Wenmode, **options: Any) -> None:
wenmode.register_rules(rules)
wenmode.register_renderer_handlers(handlers)
wenmode = Wenmode(plugins=[PlusMarkPlugin()])
expected = '''
<p><mark>very <em>important</em></mark></p>
'''
assert wenmode.render('++very *important*++') == expected.lstrip()
The parser creates the node. Renderer handlers decide how each output format
serializes that node. If a renderer has no handler for a node type,
BaseRenderer falls back to rendering child nodes or a literal value.
Renderer Handlers¶
register_renderer_handlers() accepts a mapping 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},
}
def setup(wenmode: Wenmode, **options: Any) -> None:
wenmode.register_rules([PlusMarkRule])
wenmode.register_renderer_handlers(handlers)
Use stable node type values. Renderer handlers are selected by node.type,
not by the Python class name.
When a plugin needs to add document-level output before or after the rendered
root, prefer renderer root hooks over replacing the root handler. Use
the pseudo handler names root:pre and root:post for prefixes such as
metadata blocks and suffixes. This keeps the renderer’s built-in root behavior,
such as footnote sections or deferred image definitions, intact.
handlers = {
'markdown': {'root:pre': render_document_metadata},
}
Setup Options¶
Expose options on setup() when callers need to enable part of a plugin. This
is how built-in plugins such as math and spoiler let callers install only
their inline or block syntax.
def setup(wenmode: Wenmode, inline: bool = True, block: bool = True, **options: Any) -> None:
selected_rules = []
if inline:
selected_rules.append(MyInlineRule)
if block:
selected_rules.append(MyBlockRule)
wenmode.register_rules(selected_rules)
wenmode.register_renderer_handlers(handlers)
Unknown options are accepted by convention so plugins can share a consistent
setup(wenmode, **options) shape. Validate option values inside setup() when
the plugin needs stricter behavior.
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 |
|
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'!!'
trigger_chars = '!'
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, pattern, and
trigger_chars as class attributes. Keep __init__() only when the rule needs
caller-provided configuration.
Parsing Nested Content¶
When a rule contains nested Markdown content, call parser helpers so the nested content uses the same rule set and, when enabled, the same source-position behavior.
Inline rules should pass the source range of the nested label or body:
value_start = match.end()
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 should collect the original source for generated nested text before
calling parser.parse_blocks():
source = state.source.collect()
for line in lines:
source.add(state.index, 0, line)
state.advance()
children = parser.parse_blocks(
''.join(lines),
parent_state=state,
source=source.map(),
)
If a rule decides not to handle a match, return None without consuming input.
For inline rules, return (None, match.start()). The parser will continue with
the normal fallback behavior.
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_]*'
trigger_chars = '@'
def parse(
self,
parser: Parser,
text: str,
match: re.Match[str],
state: BlockState,
) -> tuple[Node | None, int]:
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,
),
)
For nested Markdown, prefer passing a source map instead of setting every child
manually. In the ++marked++ example above, parser.inline_source() maps
children to marked, while the returned PlusMarkNode still gets the full
++marked++ range from the parser.
Root.to_ast() converts offsets to unist-style line and column fields.
Standalone nodes, including nodes yielded by Parser.parse_iter(), do not have
root line-start context and 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: {})
Root transforms 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. 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.