This commit is contained in:
CreaRobotics committed 2026-03-02 11:34:40 +01:00
1 parent 6ccab0347b
commit 954a2fd6d6
18684 files changed
+601012 -190

No files matched your search

@@ -0,0 +1,564 @@
"""Generic blocks extension."""
from __future__ import annotations
from markdown import Extension, Markdown
from markdown.blockprocessors import BlockProcessor
from markdown.treeprocessors import Treeprocessor
from markdown.blockparser import BlockParser
from markdown import util as mutil
from .. import util
import xml.etree.ElementTree as etree
import re
import yaml
import textwrap
from typing import cast, Any, TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from .block import Block
# Fenced block placeholder for SuperFences
FENCED_BLOCK_RE = re.compile(
r'^([\> ]*){}({}){}$'.format(
mutil.HTML_PLACEHOLDER[0],
mutil.HTML_PLACEHOLDER[1:-1] % r'([0-9]+)',
mutil.HTML_PLACEHOLDER[-1]
)
)
# Block start/end
RE_START = re.compile(
r'(?:^|\n)[ ]{0,3}(/{3,})[ ]*([\w-]+)[ ]*(?:\|[ ]*(.*?)[ ]*)?(?:\n|$)'
)
RE_END = re.compile(
r'(?m)(?:^|\n)[ ]{0,3}(/{3,})[ ]*(?:\n|$)'
)
# Frontmatter patterns
RE_YAML_START = re.compile(r'(?m)^[ ]{0,3}(-{3})[ ]*(?:\n|$)')
RE_YAML_END = re.compile(
r'(?m)^[ ]{0,3}(-{3})[ ]*(?:\n|$)'
)
RE_INDENT_YAML_LINE = re.compile(r'(?m)^(?:[ ]{4,}(?!\s).*?(?:\n|$))+')
class BlockEntry:
"""Track Block entries."""
def __init__(self, block: Block, el: etree.Element, parent: etree.Element) -> None:
"""Block entry."""
self.block: 'Block' = block
self.el: etree.Element = el
self.parent: etree.Element = parent
self.hungry: bool = False
def get_frontmatter(string: str) -> dict[str, Any] | None:
"""
Get frontmatter from string.
YAML-ish key value pairs.
"""
frontmatter = None
try:
frontmatter = yaml.safe_load(string)
if frontmatter is None:
frontmatter = {}
if not isinstance(frontmatter, dict):
frontmatter = None
except Exception:
pass
return cast('dict[str, Any]', frontmatter)
def reindent(text: str, pos: int, level: int) -> list[str]:
"""Reindent the code to where it is supposed to be."""
indented = []
for line in text.split('\n'):
index = pos - level
indented.append(line[index:])
return indented
def unescape_markdown(md: Markdown, blocks: list[str], is_raw: bool) -> list[str]:
"""Look for SuperFences code placeholders and other HTML stash placeholders and revert them back to plain text."""
superfences = None
try:
from ..superfences import SuperFencesBlockPreprocessor
processor = md.preprocessors['fenced_code_block']
if isinstance(processor, SuperFencesBlockPreprocessor):
superfences = processor.extension # type: ignore[attr-defined]
except Exception:
pass
new_blocks = []
for block in blocks:
new_lines = []
for line in block.split('\n'):
m = FENCED_BLOCK_RE.match(line)
if m:
key = m.group(2)
# Extract SuperFences content
indent_level = len(m.group(1))
original = None
if superfences is not None:
original, pos = superfences.stash.get(key, (None, None))
if original is not None:
code = reindent(original, pos, indent_level)
new_lines.extend(code)
superfences.stash.remove(key)
# Extract other HTML stashed content
if original is None and is_raw:
index = int(key.split(':')[1])
if index < len(md.htmlStash.rawHtmlBlocks):
original = md.htmlStash.rawHtmlBlocks[index]
if isinstance(original, etree.Element):
original = etree.tostring(original, encoding='unicode', method='html')
new_lines.append(original)
# Couldn't find anything to extract
if original is None: # pragma: no cover
new_lines.append(line)
else:
new_lines.append(line)
new_blocks.append('\n'.join(new_lines))
return new_blocks
class BlocksTreeprocessor(Treeprocessor):
"""Blocks tree processor."""
def __init__(self, md: Markdown, blocks: BlocksProcessor):
"""Initialize."""
super().__init__(md)
self.blocks = blocks
def run(self, root: etree.Element) -> None:
"""Update tab IDs."""
while self.blocks.inline_stack:
entry = self.blocks.inline_stack.pop(0)
entry.block.on_inline_end(entry.el)
class BlocksProcessor(BlockProcessor):
"""Generic block processor."""
def __init__(self, parser: BlockParser, md: Markdown) -> None:
"""Initialization."""
self.md = md
# The Block classes indexable by name
self.blocks: dict[str, type[Block]] = {}
self.config: dict[str, dict[str, Any]] = {}
self.empty_tags = {'hr',}
self.block_level_tags = set(md.block_level_elements.copy())
self.block_level_tags.add('html')
# Block-level tags in which the content only gets span level parsing
self.span_tags = {
'address', 'dd', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'legend', 'li', 'p', 'summary', 'td', 'th'
}
# Block-level tags which never get their content parsed.
self.raw_tags = {'canvas', 'math', 'option', 'pre', 'script', 'style', 'textarea', 'code'}
# Block-level tags in which the content gets parsed as blocks
self.block_tags = set(self.block_level_tags) - (self.span_tags | self.raw_tags | self.empty_tags)
self.span_and_blocks_tags = self.block_tags | self.span_tags
super().__init__(parser)
# Persistent storage across a document for blocks
self.trackers: dict[str, dict[str, Any]] = {}
# Currently queued up blocks
self.stack: list[BlockEntry] = []
# Blocks that should be processed after inline.
self.inline_stack: list[BlockEntry] = []
# When set, the assigned block is actively parsing blocks.
self.working: BlockEntry | None = None
# Cached the found parent when testing
# so we can quickly retrieve it when running
self.cached_parent: etree.Element | None = None
self.cached_block: tuple['Block', str] | None = None
# Used during the alpha/beta stage
self.start = RE_START
self.end = RE_END
self.yaml_line = RE_INDENT_YAML_LINE
def detab_by_length(self, text: str, length: int) -> tuple[str, str]:
"""Remove a tab from the front of each line of the given text."""
newtext = []
lines = text.split('\n')
for line in lines:
if line.startswith(' ' * length):
newtext.append(line[length:])
elif not line.strip():
newtext.append('') # pragma: no cover
else:
break
if newtext:
return '\n'.join(newtext), '\n'.join(lines[len(newtext):])
return '\n'.join(lines[len(newtext):]), ''
def register(self, b: type[Block], config: dict[str, Any]) -> None:
"""Register a block."""
if b.NAME in self.blocks:
raise ValueError(f'The block name {b.NAME} is already registered!')
self.blocks[b.NAME] = b
self.config[b.NAME] = config
self.trackers[b.NAME] = {}
def test(self, parent: etree.Element, block: str) -> bool:
"""Test to see if we should process the block."""
# Are we hungry for more?
if self.get_parent(parent) is not None:
return True
# Is this the start of a new block?
m = self.start.search(block)
if m:
pre_text = block[:m.start()] if m.start() > 0 else None
# Create a block object
name = m.group(2).lower()
if name in self.blocks:
generic_block = self.blocks[name](len(m.group(1)), self.trackers[name], self, self.config[name])
# Remove first line
block = block[m.end():]
# Get frontmatter and argument(s)
options, the_rest = self.split_header(block, generic_block.length)
arguments = m.group(3)
# Options must be valid
status = options is not None
# Update the config for the Block
if status:
status = generic_block._validate(parent, arguments, **options) # type: ignore[arg-type]
# Cache the found Block and any remaining content
if status:
self.cached_block = (generic_block, the_rest)
# Any text before the block should get handled
if pre_text is not None:
self.parser.parseBlocks(parent, [pre_text])
return status
return False
def _reset(self) -> None:
"""Reset."""
self.stack.clear()
self.inline_stack.clear()
self.working = None
self.trackers = {d: {} for d in self.blocks.keys()}
def split_end(self, block: str, length: int) -> tuple[str | None, str | None, bool]:
"""Search for end and split the blocks while removing the end."""
good = None
bad = None
end = False
# Find the end of the Block
m = None
for match in self.end.finditer(block):
if len(match.group(1)) == length:
m = match
break
# Separate everything from before the "end" and after
if m:
temp = block[:m.start(0)]
if temp:
good = temp[:-1] if temp.endswith('\n') else temp
end = True
# Since we found our end, everything after is unwanted
temp = block[m.end(0):]
if temp:
bad = temp
else:
# Gather blocks until we find our end
good = block
# Send back the new list of blocks to parse and note whether we found our end
return good, bad, end
def split_header(self, block: str, length: int) -> tuple[dict[str, Any] | None, str]:
"""Split, YAML-ish header out."""
# Search for end in first block
m = None
blocks: list[str] = []
for match in self.end.finditer(block):
if len(match.group(1)) == length:
m = match
break
# Move block ending to be parsed later
if m:
end = block[m.start(0):]
blocks.insert(0, end)
block = block[:m.start(0)]
m = self.yaml_line.match(block)
if m is not None:
config = textwrap.dedent(m.group(0))
blocks.insert(0, block[m.end():])
if config.strip():
return get_frontmatter(config), '\n'.join(blocks)
blocks.insert(0, block)
return {}, '\n'.join(blocks)
def get_parent(self, parent: etree.Element) -> etree.Element | None:
"""Get parent."""
# Returned the cached parent from our last attempt
if self.cached_parent is not None:
parent = self.cached_parent
self.cached_parent = None
return parent
temp: etree.Element | None = parent
while temp is not None:
if not self.stack:
break
if self.stack[-1].hungry and self.stack[-1].parent is temp:
self.cached_parent = temp
return temp
if temp is not None:
temp = self.lastChild(temp)
return None
def is_raw(self, tag: etree.Element) -> bool:
"""Is tag raw."""
return tag.tag in self.raw_tags
def is_block(self, tag: etree.Element) -> bool:
"""Is tag block."""
return tag.tag in self.block_tags
def parse_blocks(self, blocks: list[str], current_parent: etree.Element) -> None:
"""Parse the blocks."""
# Get the target element and parse
while blocks and self.stack:
b: str | None = blocks.pop(0)
# Get the latest block on the stack
# This is required to avoid some issues with `md_in_html`
entry = self.stack[-1]
target = entry.block.on_add(entry.el)
# Since we are juggling the block parsers on the stack, the pipeline
# has not fully adjusted list indentation, so look at how many
# list item parents we have on the stack and adjust the content
# accordingly.
parent_map = {c: p for p in current_parent.iter() for c in p}
# Only need to count lists between nested blocks
parent = self.stack[-1].el if len(self.stack) > 1 else None
li = 0
while parent is not None:
parent = parent_map.get(parent, None)
if parent is not None:
if parent.tag in ('li', 'dd'):
li += 1
continue
break
b, a = self.detab_by_length(cast(str, b), li * self.tab_length)
if a:
blocks.insert(0, a)
# Split out blocks we care about
b, bad, end = self.split_end(b, entry.block.length)
if bad is not None:
blocks.insert(0, bad)
# Parse the block under the given target
if b is not None and target is not None:
# Resolve modes
mode = entry.block.on_markdown()
if mode not in ('block', 'inline', 'raw'):
mode = 'auto'
is_block = mode == 'block' or (mode == 'auto' and self.is_block(target))
is_atomic = mode == 'raw' or (mode == 'auto' and self.is_raw(target))
# We should revert fenced code in spans or atomic tags.
# Make sure atomic tags have content wrapped as `AtomicString`.
if is_atomic or not is_block:
child = list(target)[-1] if len(target) else None
text = target.text if child is None else child.tail
b = '\n\n'.join(unescape_markdown(self.md, [b], is_atomic)).strip('\n')
if text:
text += b if not b else '\n\n' + b
else:
text = b
if child is None:
target.text = mutil.AtomicString(text) if is_atomic else text
else: # pragma: no cover
# TODO: We would need to build a special plugin to test this,
# as none of the default ones do this, but we have verified this
# locally. Once we've written a test, we can remove this.
child.tail = mutil.AtomicString(text) if is_atomic else text
# Block tags should have content go through the normal block processor
else:
self.parser.state.set('blocks')
working = self.working
self.working = entry
self.parser.parseChunk(target, b)
self.parser.state.reset()
self.working = working
# Run "on end" event when we finish a block
if end:
entry.block._end(entry.el)
self.inline_stack.append(entry)
del self.stack[-1]
# The Block does not or no longer accepts more content
if target is None: # pragma: no cover
break
if self.stack:
self.stack[-1].hungry = True
def capture_leaked_content(self, parent: etree.Element, entry: BlockEntry) -> None:
"""
Capture leaked content.
Old school, non-block admonitions, details,
and content tabs strongly control where there content is inserted and
can cause content leakage outside of the Blocks container.
Look for such content and pull it back into the container if found.
"""
last_child = self.lastChild(parent)
if last_child is not None and last_child is not entry.el:
target = entry.block.on_add(entry.el)
parent.remove(last_child)
target.append(last_child)
def run(self, parent: etree.Element, blocks: list[str]) -> None:
"""Convert to details/summary block."""
# Get the appropriate parent for this Block
temp = self.get_parent(parent)
if temp is not None:
parent = temp
# Did we find a new Block?
if self.cached_block:
# Get cached Block and reset the cache
generic_block, block = self.cached_block
self.cached_block = None
# Discard first block as we've already processed what we need from it
blocks.pop(0)
if block:
blocks.insert(0, block)
# Ensure a "tight" parent list item is converted to "loose".
if parent is not None and parent.tag in ('li', 'dd'): # pragma: no cover
text = parent.text
if parent.text:
parent.text = ''
p = etree.SubElement(parent, 'p')
p.text = text
# Create the block element
el = generic_block._create(parent)
# Push a Block entry on the stack.
self.stack.append(BlockEntry(generic_block, el, parent))
# Parse the text blocks under the Block
self.parse_blocks(blocks, parent)
else:
for r in range(len(self.stack)):
entry = self.stack[r]
if entry.hungry and parent is entry.parent:
# Capture leaked content from old-school extensions: admonition, details, tabbed, etc.
self.capture_leaked_content(parent, entry)
# Get the target element and parse
entry.hungry = False
self.parse_blocks(blocks, parent)
break
class BlocksMgrExtension(Extension):
"""Add generic Blocks extension."""
def extendMarkdown(self, md: Markdown) -> None:
"""Add Blocks to Markdown instance."""
md.registerExtension(self)
util.escape_chars(md, ['/'])
self.extension = BlocksProcessor(md.parser, md)
# We want to be right after list indentations are processed
md.parser.blockprocessors.register(self.extension, "blocks", 89.99)
tree = BlocksTreeprocessor(md, self.extension)
md.treeprocessors.register(tree, 'blocks_on_inline_end', 19.99)
def reset(self) -> None:
"""Reset."""
self.extension._reset()
class BlocksExtension(Extension):
"""Blocks Extension."""
def register_block_mgr(self, md: Markdown) -> BlocksProcessor:
"""Add Blocks to Markdown instance."""
if 'blocks' not in md.parser.blockprocessors:
ext = BlocksMgrExtension()
ext.extendMarkdown(md)
mgr = ext.extension
else:
mgr = cast('BlocksProcessor', md.parser.blockprocessors['blocks'])
return mgr
def extendMarkdown(self, md: Markdown) -> None:
"""Extend markdown."""
mgr = self.register_block_mgr(md)
self.extendMarkdownBlocks(md, mgr)
def extendMarkdownBlocks(self, md: Markdown, block_mgr: BlocksProcessor) -> None:
"""Extend Markdown blocks."""
@@ -0,0 +1,125 @@
"""Admonitions."""
import xml.etree.ElementTree as etree
from .block import Block, type_html_identifier
from .. blocks import BlocksExtension
import re
RE_SEP = re.compile(r'[_-]+')
class Admonition(Block):
"""
Admonition.
Arguments (1 optional):
- A title.
Options:
- `type` (string): Attach a single special class for styling purposes. If more are needed,
use the built-in `attributes` options to apply as many classes as desired.
Content:
Detail body.
"""
NAME = 'admonition'
ARGUMENT = None
OPTIONS = {
'type': ('', type_html_identifier),
}
DEF_TITLE = None
DEF_CLASS = None
def on_validate(self, parent):
"""Handle on validate event."""
if self.NAME != 'admonition':
self.options['type'] = {'name': self.NAME}
if self.DEF_TITLE:
self.options['type']['title'] = self.DEF_TITLE
if self.DEF_TITLE:
self.options['type']['class'] = self.DEF_CLASS
return True
def on_create(self, parent):
"""Create the element."""
# Set classes
classes = ['admonition']
obj = self.options['type']
atype = def_title = class_name = ''
if isinstance(obj, dict):
atype = obj['name']
class_name = obj.get('class', atype)
def_title = obj.get('title', RE_SEP.sub(' ', class_name).title())
elif isinstance(obj, str):
atype = obj
class_name = atype
def_title = RE_SEP.sub(' ', atype).title()
if atype and atype != 'admonition':
classes.append(class_name)
# Create the admonition
el = etree.SubElement(parent, 'div', {'class': ' '.join(classes)})
# Create the title
title = None
if self.argument is None:
if atype:
title = def_title
elif self.argument:
title = self.argument
if title is not None:
ad_title = etree.SubElement(el, 'p', {'class': 'admonition-title'})
ad_title.text = title
return el
class AdmonitionExtension(BlocksExtension):
"""Admonition Blocks Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
"types": [
['note', 'attention', 'caution', 'danger', 'error', 'tip', 'hint', 'warning', 'important'],
"Generate Admonition block extensions for the given types."
]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(Admonition, self.getConfigs())
# Generate an admonition subclass based on the given names.
for obj in self.getConfig('types', []):
if isinstance(obj, dict):
name = obj['name']
class_name = obj.get('class', name)
title = obj.get('title', RE_SEP.sub(' ', class_name).title())
else:
name = obj
class_name = name
title = RE_SEP.sub(' ', class_name).title()
subclass = RE_SEP.sub('', name).title()
block_mgr.register(
type(
subclass,
(Admonition,),
{'OPTIONS': {}, 'NAME': name, 'DEF_TITLE': title, 'DEF_CLASS': class_name}
),
{}
)
def makeExtension(*args, **kwargs):
"""Return extension."""
return AdmonitionExtension(*args, **kwargs)
@@ -0,0 +1,401 @@
"""Block class."""
from __future__ import annotations
from abc import ABCMeta, abstractmethod
import functools
import copy
import re
import sys
from markdown import util as mutil
import xml.etree.ElementTree as etree
from typing import Any, Callable, TypeVar, TYPE_CHECKING
from collections.abc import Iterable
if TYPE_CHECKING: # pragma: no cover
from ..blocks import BlocksProcessor
RE_IDENT = re.compile(
r'''
(?:(?:-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f])+|--)
(?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f])*)
''',
re.I | re.X
)
RE_INDENT = re.compile(r'(?m)^([ ]*)[^ \n]')
RE_DEDENT = re.compile(r'(?m)^([ ]*)($)?')
_T = TypeVar("_T")
def _type_multi(value: Any, types: Iterable[Callable[[Any], _T]] = ()) -> _T:
"""Multi types."""
for t in types:
try:
return t(value)
except ValueError: # noqa: PERF203
pass
raise ValueError(f"Type '{type(value)}' did not match any of the provided types")
def type_multi(*args: Callable[[Any], _T]) -> Callable[[Any], _T]:
"""Validate a type with multiple type functions."""
return functools.partial(_type_multi, types=args)
def type_any(value: _T) -> _T:
"""Accepts any type."""
return value
def type_none(value: Any) -> None:
"""Ensure type None or fail."""
if value is not None:
raise ValueError(f'{type(value)} is not None')
def _ranged_number(
value: Any,
minimum: int | float | None,
maximum: int | float | None,
number_type: Callable[[Any], int | float]
) -> int | float:
"""Check the range of the given number type."""
_value = number_type(value)
if minimum is not None and _value < minimum:
raise ValueError(f'{_value} is not greater than {minimum}')
if maximum is not None and _value > maximum:
raise ValueError(f'{_value} is not greater than {minimum}')
return _value
def type_number(value: Any) -> int | float:
"""Ensure type number or fail."""
if not isinstance(value, (float, int)):
raise ValueError(f"Could not convert type {type(value)} to a number")
return value
def type_integer(value: Any) -> int:
"""Ensure type integer or fail."""
if isinstance(value, int):
return value
if not isinstance(value, float) or not value.is_integer():
raise ValueError(f"Could not convert type {type(value)} to an integer")
return int(value)
def type_ranged_number(minimum: int | None = None, maximum: int | None = None) -> Callable[[Any], int | float]:
"""Ensure typed number is within range."""
return functools.partial(_ranged_number, minimum=minimum, maximum=maximum, number_type=type_number)
def type_ranged_integer(minimum: int | None = None, maximum: int | None = None) -> Callable[[Any], int | float]:
"""Ensured type integer is within range."""
return functools.partial(_ranged_number, minimum=minimum, maximum=maximum, number_type=type_integer)
def type_boolean(value: Any) -> bool:
"""Ensure type boolean or fail."""
if not isinstance(value, bool):
raise ValueError(f"Could not convert type {type(value)} to a boolean")
return value
type_ternary = type_multi(type_none, type_boolean)
def type_string(value: Any) -> str:
"""Ensure type string or fail."""
if isinstance(value, str):
return value
raise ValueError(f"Could not convert type {type(value)} to a string")
def type_string_insensitive(value: Any) -> str:
"""Ensure type string and normalize case."""
return type_string(value).lower()
def type_html_identifier(value: Any) -> str:
"""Ensure type HTML attribute name or fail."""
value = type_string(value)
m = RE_IDENT.fullmatch(value)
if m is None:
raise ValueError('A valid attribute name must be provided')
return m.group(0)
def _delimiter(string: Any, split: str, string_type: Callable[[Any], str]) -> list[str]:
"""Split the string by the delimiter and then parse with the parser."""
l = []
# Ensure input is a string
_string = type_string(string)
for s in _string.split(split):
s = s.strip()
if not s:
continue
# Ensure each part conforms to the desired string type
s = string_type(s)
l.append(s)
return l
def _string_in(value: Any, accepted: Iterable[str], string_type: Callable[[Any], str]) -> str:
"""Ensure type string is within the accepted values."""
_value = string_type(value)
if _value not in accepted:
raise ValueError(f'{_value} not found in {accepted!s}')
return _value
def type_string_in(accepted: Iterable[str], insensitive: bool = True) -> Callable[[Any], str]:
"""Ensure type string is within the accepted list."""
return functools.partial(
_string_in,
accepted=accepted,
string_type=type_string_insensitive if insensitive else type_string
)
def type_string_delimiter(split: str, string_type: Callable[[Any], str] = type_string) -> Callable[[Any], list[str]]:
"""String delimiter function."""
return functools.partial(_delimiter, split=split, string_type=string_type)
def type_html_attribute_dict(value: Any) -> dict[str, str | list[str]]:
"""Attribute dictionary."""
if not isinstance(value, dict):
raise ValueError('Attributes should be contained within a dictionary')
attributes = {}
for k, v in value.items():
k = type_html_identifier(k)
if k.lower() == 'class':
k = 'class'
v = type_html_classes(v)
elif k.lower() == 'id':
k = 'id'
v = type_html_identifier(v)
else:
v = type_string(v)
attributes[k] = v
return attributes
# Ensure class(es) or fail
type_html_classes = type_string_delimiter(' ', type_html_identifier)
class Block(metaclass=ABCMeta):
"""Block."""
# Set to something if argument should be split.
# Arguments will be split and white space stripped.
NAME = ''
# Instance arguments and options
ARGUMENT: bool | None = False
OPTIONS: dict[str, tuple[Any, Callable[[Any], Any]]] = {}
def __init__(self, length: int, tracker: Any, block_mgr: BlocksProcessor, config: Any):
"""
Initialize.
- `length` specifies the length (number of slashes) that the header used
- `tracker` is a persistent storage for the life of the current Markdown page.
It is a dictionary where we can keep references until the parent extension is reset.
- `md` is the Markdown object just in case access is needed to something we
didn't think about.
"""
# Setup up the argument and options spec
# Note that `attributes` is handled special and we always override it
self.arg_spec = self.ARGUMENT
self.option_spec = copy.deepcopy(self.OPTIONS)
if 'attrs' in self.option_spec: # pragma: no cover
raise ValueError("'attrs' is a reserved option name and cannot be overriden")
self.option_spec['attrs'] = ({}, type_html_attribute_dict)
self._block_mgr = block_mgr
self.length = length
self.tracker = tracker
self.md = block_mgr.md
self.arguments: list[Any] = []
self.options: dict[str, Any] = {}
self.config = config
self.on_init()
def is_raw(self, tag: etree.Element) -> bool:
"""Is raw element."""
return self._block_mgr.is_raw(tag)
def is_block(self, tag: etree.Element) -> bool: # pragma: no cover
"""Is block element."""
return self._block_mgr.is_block(tag)
def html_escape(self, text: str) -> str:
"""Basic html escaping."""
text = text.replace('&', '&amp;')
text = text.replace('<', '&lt;')
text = text.replace('>', '&gt;')
return text
def dedent(self, text: str, length: int | None = None) -> str:
"""Dedent raw text."""
if length is None:
length = self.md.tab_length
min_length = sys.maxsize
for x in RE_INDENT.findall(text):
min_length = min(len(x), min_length)
min_length = min(min_length, length)
def on_match(m: re.Match[str], l: int = min_length) -> str:
return '' if m.group(2) is not None else m.group(1)[l:]
return RE_DEDENT.sub(on_match, text)
def on_init(self) -> None:
"""On initialize."""
return
def on_markdown(self) -> str:
"""Check how element should be treated by the Markdown parser."""
return "auto"
def _validate(self, parent: etree.Element, arg: Any, **options: Any) -> bool:
"""Parse configuration."""
# Check argument
if (self.arg_spec is not None and ((arg and not self.arg_spec) or (not arg and self.arg_spec))):
return False
self.argument = arg
# Fill in defaults options
spec = self.option_spec
parsed = {}
for k, v in spec.items():
parsed[k] = v[0]
# Parse provided options
for k, v in options.items():
# Parameter not in spec
if k not in spec:
# Unrecognized parameter name
return False
# Spec explicitly handles parameter
else:
parser = spec[k][1]
if parser is not None:
try:
v = parser(v)
except Exception:
# Invalid parameter value
return False
parsed[k] = v
# Add parsed options to options
self.options = parsed
return self.on_validate(parent)
def on_validate(self, parent: etree.Element) -> bool:
"""
Handle validation event.
Run after config parsing completes and allows for the opportunity
to invalidate the block if argument, options, or even the parent
element do not meet certain criteria.
Return `False` to invalidate the block.
"""
return True
@abstractmethod
def on_create(self, parent: etree.Element) -> etree.Element:
"""Create the needed element and return it."""
def _create(self, parent: etree.Element) -> etree.Element:
"""Create the element."""
el = self.on_create(parent)
# Handle general HTML attributes
attrib = el.attrib
for k, v in self.options['attrs'].items():
if k == 'class':
if k in attrib:
# Don't validate what the developer as already attached
v = type_string_delimiter(' ')(attrib['class']) + v
attrib['class'] = ' '.join(v)
else:
attrib[k] = v
return el
def _end(self, block: etree.Element) -> None:
"""Reached end of the block, dedent raw blocks and call `on_end` hook."""
mode = self.on_markdown()
add = self.on_add(block)
if mode == 'raw' or (mode == 'auto' and self.is_raw(add)):
text = add.text if add.text is not None else ''
add.text = mutil.AtomicString(self.dedent(text))
self.on_end(block)
def on_end(self, block: etree.Element) -> None:
"""Perform any action on end."""
return
def on_add(self, block: etree.Element) -> etree.Element:
"""
Adjust where the content is added and return the desired element.
Is there a sub-element where this content should go?
This runs before processing every new block.
"""
return block
def on_inline_end(self, block: etree.Element) -> None:
"""Perform action on the block after inline parsing."""
return
@@ -0,0 +1,417 @@
"""
Captions.
Captions should be placed after a block, that block will be wrapped in a `figure`
and captions will be inserted either at the end of the figure or at the beginning.
If the preceding block happens to be a `figure`, if no `figcaption` is detected
within, the caption will be injected into that figure instead of wrapping.
Keep in mind that when `md_in_html` is used and raw HTML is used, if `markdown=1`
is not present on the caption, the caption will be invisible to this extension.
Class, IDs, or other attributes will be attached to the figure, not the caption.
`types`:
A dictionary with figure type names and prefix templates. A template will be
used depending on whether the current type is assumed or directly specified.
`prepend`:
Will prepend `figcaption` at the start of a `figure` instead of the end.
`auto`:
Will generate IDs and prefixes via the provided template for all figures of
a given type as long as they also define a prefix template.
`auto_level`:
Auto number will not be shown below the given level depth. A value of 0, the
default, disables the feature, 1 would show only auto-generate IDs and
prefixes for the outermost figures with prefixes, etc. This level is only
considered for each figure type individually.
"""
import xml.etree.ElementTree as etree
from .block import Block, type_html_identifier
from .. blocks import BlocksExtension
from .html import parse_selectors
from markdown.treeprocessors import Treeprocessor
import re
RE_FIG_NUM = re.compile(r'^(\^)?([1-9][0-9]*(?:\.[1-9][0-9]*)*)(?= |$)')
RE_SEP = re.compile(r'[_-]+')
def update_tag(el, fig_type, fig_num, template, prepend, md):
"""Update tag ID and caption prefix."""
# Auto add an ID
if 'id' not in el.attrib:
el.attrib['id'] = f'__{fig_type}_' + '_'.join(str(x) for x in fig_num.split('.'))
# Prefix the caption with a given numbered prefix
if template:
for child in list(el) if prepend else reversed(el):
if child.tag == 'figcaption':
children = list(child)
value = md.htmlStash.store(template.format(fig_num))
if not len(children) or children[0].tag != 'p':
p = etree.Element('p')
span = etree.SubElement(p, 'span', {'class': 'caption-prefix'})
span.text = value
p.tail = child.text
child.text = None
child.insert(0, p)
else:
p = children[0]
span = etree.Element('span', {'class': 'caption-prefix'})
span.text = value
empty = not bool(p.text)
span.tail = (' ' + p.text) if not empty else p.text
p.text = None
p.insert(0, span)
class CaptionTreeprocessor(Treeprocessor):
"""Caption tree processor."""
def __init__(self, md, types, config):
"""Initialize."""
super().__init__(md)
self.auto = config['auto']
self.prepend = config['prepend']
self.type = ''
self.auto_level = max(0, config['auto_level'])
self.fig_types = types
def run(self, doc):
"""Update caption IDs and prefixes."""
parent_map = {c: p for p in doc.iter() for c in p}
last = dict.fromkeys(self.fig_types, 0)
counters = {k: [0] for k in self.fig_types}
fig_type = last_type = self.type
figs = []
fig_num = ''
# Calculate the depth and iteration at that depth of the given figure.
for el in doc.iter():
fig_num = ''
stack = -1
if el.tag == 'figure':
fig_type = last_type
prepend = False
skip = False
# Find caption appended or prepended
if '__figure_prepend' in el.attrib:
prepend = True
del el.attrib['__figure_prepend']
# Determine figure type
if '__figure_type' in el.attrib:
fig_type = el.attrib['__figure_type']
figs.append(el)
# See if we have an unknown type or the type has no prefix template.
if fig_type not in self.fig_types or not self.fig_types[fig_type]:
continue
else:
# Found a figure that was not generated by this plugin.
continue
# Handle a specified relative nesting depth
if '__figure_level' in el.attrib:
stack += int(el.attrib['__figure_level']) + 1
if self.auto_level and stack >= self.auto_level:
continue
else:
stack += 1
current = el
while True:
parent = parent_map.get(current, None)
# No more parents
if parent is None:
break
# Check if parent element is a figure of the current type
if parent.tag == 'figure' and parent.attrib['__figure_type'] == fig_type:
# See if position in stack is manually specified
level = '__figure_level' in parent.attrib
if level:
stack += int(parent.attrib['__figure_level']) + 1
else:
stack += 1
if level:
el.attrib['__figure_level'] = str(stack + 1)
# Ensure position in stack is not deeper than the specified level
if self.auto_level and stack >= self.auto_level:
skip = True
break
current = parent
if skip:
# Parent has been skipped so all children are also skipped
continue
# Found an appropriate figure at an acceptable depth
if stack > -1:
# Handle a manual number
if '__figure_num' in el.attrib:
fig_num = [int(x) for x in el.attrib['__figure_num'].split('.')]
del el.attrib['__figure_num']
new_stack = len(fig_num) - 1
el.attrib['__figure_level'] = new_stack - stack
stack = new_stack
# Increment counter
l = last[fig_type]
counter = counters[fig_type]
if stack > l:
counter.extend([1] * (stack - l))
elif stack == l:
counter[stack] += 1
else:
del counter[stack + 1:]
counter[-1] += 1
last[fig_type] = stack
last_type = fig_type
# Determine if manual number is not smaller than existing figure numbers at that depth
if fig_num and fig_num > counter:
counter[:] = fig_num[:]
# Apply prefix and ID
update_tag(
el,
fig_type,
'.'.join(str(x) for x in counter[:stack + 1]),
self.fig_types.get(fig_type, ''),
prepend,
self.md
)
# Clean up attributes
for fig in figs:
del fig.attrib['__figure_type']
if '__figure_level' in fig.attrib:
del fig.attrib['__figure_level']
class Caption(Block):
"""Figure captions."""
NAME = ''
PREFIX = ''
CLASSES = ''
ARGUMENT = None
OPTIONS = {
'type': ('', type_html_identifier)
}
def on_init(self):
"""Initialize."""
self.auto = self.config['auto']
self.prepend = self.config['prepend']
self.caption = None
self.fig_num = ''
self.level = ''
self.classes = self.CLASSES.split()
def on_validate(self, parent):
"""Handle on validate event."""
argument = self.argument
if argument:
if argument.startswith('>'):
self.prepend = False
argument = argument[1:].lstrip()
elif argument.startswith('<'):
self.prepend = True
argument = argument[1:].lstrip()
m = RE_FIG_NUM.match(argument)
if m:
if m.group(1):
self.level = m.group(2)
else:
self.fig_num = m.group(2)
argument = argument[m.end():].lstrip()
if argument:
try:
_, attrs = parse_selectors(argument, require_tag=False)
except ValueError:
return False
attrs_original = dict(self.options['attrs'])
for k, v in attrs.items():
if k == 'class':
classes = {x for x in attrs_original.get('class', []) if x}
classes |= {x for x in v.split(' ') if x}
attrs_original['class'] = sorted(classes)
elif k not in attrs_original:
attrs_original[k] = v
self.options['attrs'] = attrs_original
return True
return True
def on_create(self, parent):
"""Create the element."""
# Find sibling to add caption to.
fig = None
child = None
children = list(parent)
if children:
child = children[-1]
# Do we have a figure with no caption?
if child.tag == 'figure':
fig = child
for c in list(child):
if c.tag == 'figcaption':
fig = None
break
# Create a new figure if sibling is not a figure or already has a caption.
# Add sibling to the new figure.
if fig is None:
attrib = {} if not self.classes else {'class': ' '.join(self.classes)}
fig = etree.SubElement(parent, 'figure', attrib)
if child is not None:
fig.append(child)
parent.remove(child)
# Add classes to existing figure
elif self.CLASSES:
classes = fig.attrib.get('class', '').strip()
if classes:
class_list = classes.split()
for c in self.classes:
if c not in class_list:
classes += " " + c
else:
classes = ' '.join(self.classes)
fig.attrib['class'] = classes
if self.auto:
fig.attrib['__figure_type'] = self.NAME
if self.level:
fig.attrib['__figure_level'] = self.level
if self.fig_num:
fig.attrib['__figure_num'] = self.fig_num
# Add caption to the target figure.
if self.prepend:
if self.auto:
fig.attrib['__figure_prepend'] = "1"
self.caption = etree.Element('figcaption')
fig.insert(0, self.caption)
else:
self.caption = etree.SubElement(fig, 'figcaption')
return fig
def on_add(self, block):
"""Return caption as the target container for content."""
return self.caption
def on_end(self, block):
"""Handle explicit, manual prefixes on block end."""
prefix = self.PREFIX
if prefix and not self.auto:
# Levels should not be used in manual mode, but if they are, give a generic result.
if self.level:
self.fig_num = '.'.join(['1'] * (int(self.level) + 1))
if self.fig_num:
update_tag(
block,
self.NAME,
self.fig_num,
prefix,
self.prepend,
self.md
)
class CaptionExtension(BlocksExtension):
"""Caption Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
"types": [
[
'caption',
{
'name': 'figure-caption',
'prefix': 'Figure {}.'
},
{
'name': 'table-caption',
'prefix': 'Table {}.'
}
],
"Configure types a list of types, each type is a dictionary that defines a 'name' and 'prefix' "
"A template must contain '{}' for numerical insertions unless the template is an empty string "
"which will assume no prefix should be used."
],
"auto_level": [
0,
"Depth of children to add prefixes to - Default: 0"
],
"auto": [
True,
"Auto add IDs with prefixes (prefixes are only added if prefix template is defined) - Default: False"
],
"prepend": [
False,
"Prepend captions opposed to appending - Default: False"
]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
config = self.getConfigs()
# Generate an details subclass based on the given names.
types = {}
for obj in config['types']:
if isinstance(obj, dict):
name = obj['name']
prefix = obj.get('prefix', '')
classes = obj.get('classes', '')
else:
name = obj
prefix = ''
classes = ''
types[name] = prefix
subclass = RE_SEP.sub('', name).title()
block_mgr.register(
type(
subclass,
(Caption,),
{
'OPTIONS': {},
'NAME': name,
'PREFIX': prefix,
'CLASSES': classes
}
),
{'auto_level': config['auto_level'], 'auto': config['auto'], 'prepend': config['prepend']}
)
if config['auto']:
md.treeprocessors.register(CaptionTreeprocessor(md, types, config), 'caption-auto', 4)
def makeExtension(*args, **kwargs):
"""Return extension."""
return CaptionExtension(*args, **kwargs)
@@ -0,0 +1,65 @@
"""Definition."""
import xml.etree.ElementTree as etree
from .block import Block
from ..blocks import BlocksExtension
class Definition(Block):
"""
Definition.
Converts non `ul`, `ol` blocks (ideally `p` tags) into `dt`
and will convert first level `li` elements of `ul` and `ol`
elements to `dd` tags. When done, the `ul`, and `ol` elements
will be removed.
"""
NAME = 'define'
def on_create(self, parent):
"""Create the element."""
return etree.SubElement(parent, 'dl')
def on_end(self, block):
"""Convert non list items to details."""
remove = []
offset = 0
for i, child in enumerate(list(block)):
if child.tag.lower() in ('dt', 'dd'):
continue
elif child.tag.lower() not in ('ul', 'ol'):
if child.tag.lower() == 'p':
child.tag = 'dt'
else:
dt = etree.Element('dt')
dt.append(child)
block.insert(i + offset, dt)
block.remove(child)
else:
for li in list(child):
offset += 1
li.tag = 'dd'
block.insert(i + offset, li)
child.remove(li)
remove.append(child)
for el in remove:
block.remove(el)
class DefinitionExtension(BlocksExtension):
"""Definition Blocks Extension."""
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(Definition, self.getConfigs())
def makeExtension(*args, **kwargs):
"""Return extension."""
return DefinitionExtension(*args, **kwargs)
@@ -0,0 +1,138 @@
"""Details."""
import xml.etree.ElementTree as etree
from .block import Block, type_boolean, type_html_identifier
from ..blocks import BlocksExtension
import re
RE_SEP = re.compile(r'[_-]+')
class Details(Block):
"""
Details.
Arguments (1 optional):
- A summary.
Options:
- `open` (boolean): force the details block to be in an open state opposed to collapsed.
- `type` (string): Attach a single special class for styling purposes. If more are needed,
use the built-in `attributes` options to apply as many classes as desired.
Content:
Detail body.
"""
NAME = 'details'
ARGUMENT = None
OPTIONS = {
'open': (False, type_boolean),
'type': ('', type_html_identifier)
}
DEF_TITLE = None
DEF_CLASS = None
def on_validate(self, parent):
"""Handle on validate event."""
if self.NAME != 'details':
self.options['type'] = {'name': self.NAME}
if self.DEF_TITLE:
self.options['type']['title'] = self.DEF_TITLE
if self.DEF_TITLE:
self.options['type']['class'] = self.DEF_CLASS
return True
def on_create(self, parent):
"""Create the element."""
# Is it open?
attributes = {}
if self.options['open']:
attributes['open'] = 'open'
# Set classes
obj = self.options['type']
dtype = def_title = class_name = ''
if isinstance(obj, dict):
dtype = obj['name']
class_name = obj.get('class', dtype)
def_title = obj.get('title', RE_SEP.sub(' ', class_name).title())
elif isinstance(obj, str):
dtype = obj
class_name = dtype
def_title = RE_SEP.sub(' ', class_name).title()
if dtype:
attributes['class'] = class_name
# Create Detail element
el = etree.SubElement(parent, 'details', attributes)
# Create the summary
summary = None
if self.argument is None:
if dtype:
summary = def_title
elif self.argument:
summary = self.argument
# Create the summary
if summary is not None:
s = etree.SubElement(el, 'summary')
s.text = summary
return el
class DetailsExtension(BlocksExtension):
"""Admonition Blocks Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
"types": [
[],
"Generate Admonition block extensions for the given types."
]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(Details, self.getConfigs())
# Generate an details subclass based on the given names.
for obj in self.getConfig('types', []):
if isinstance(obj, dict):
name = obj['name']
class_name = obj.get('class', name)
title = obj.get('title', RE_SEP.sub(' ', class_name).title())
else:
name = obj
class_name = name
title = RE_SEP.sub(' ', class_name).title()
subclass = RE_SEP.sub('', name).title()
block_mgr.register(
type(
subclass,
(Details,),
{
'OPTIONS': {'open': [False, type_boolean]},
'NAME': name,
'DEF_TITLE': title,
'DEF_CLASS': class_name
}
),
{}
)
def makeExtension(*args, **kwargs):
"""Return extension."""
return DetailsExtension(*args, **kwargs)
@@ -0,0 +1,219 @@
"""HTML."""
import xml.etree.ElementTree as etree
from .block import Block, type_string_in
from ..blocks import BlocksExtension
import re
# Sub-patterns parts
# Whitespace
WS = r'(?:[ \t])'
# CSS escapes
CSS_ESCAPES = fr'(?:\\(?:[a-f0-9]{{1,6}}{WS}?|[^\r\n\f]|$))'
# CSS Identifier
IDENTIFIER = r'''
(?:(?:-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f])+|--)
(?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f])*)
'''
# Value: quoted string or identifier
VALUE = r'''
(?:"(?:\\(?:.)|[^\\"\r\n\f]+)*?"|'(?:\\(?:.)|[^\\'\r\n\f]+)*?'|{ident}+)
'''.format(ident=IDENTIFIER)
# Attribute value comparison.
ATTR = r'''
(?:{ws}*(?P<cmp>=){ws}*(?P<value>{value}))?
'''.format(ws=WS, value=VALUE)
# Selector patterns
# IDs (`#id`)
PAT_ID = fr'\#{IDENTIFIER}'
# Classes (`.class`)
PAT_CLASS = fr'\.{IDENTIFIER}'
# Attributes (`[attr]`, `[attr=value]`, etc.)
PAT_ATTR = r'''
\[(?:{ws}*(?P<attr_name>{ident}){attr})+{ws}*\]
'''.format(ws=WS, ident=IDENTIFIER, attr=ATTR)
RE_IDENT = re.compile(IDENTIFIER, flags=re.I | re.X)
RE_ID = re.compile(PAT_ID, flags=re.I | re.X)
RE_CLASS = re.compile(PAT_CLASS, flags=re.I | re.X)
RE_ATTRS = re.compile(PAT_ATTR, flags=re.I | re.X)
RE_ATTR = re.compile(fr'(?P<attr_name>{IDENTIFIER}){ATTR}', flags=re.I | re.X)
ATTRIBUTES = {'id': RE_ID, 'class': RE_CLASS, 'attr': RE_ATTRS}
VALID_MODES = {'auto', 'inline', 'block', 'raw', 'html'}
def parse_selectors(selector, require_tag=True):
"""Parse the selector."""
eol = len(selector)
tag = None
attrs = {}
end = 0
m = None
if require_tag:
m = RE_IDENT.match(selector)
if m is None:
raise ValueError('No defined tag')
tag = m.group(0)
end = m.end()
found_id = False
while end < eol:
for atype, pat in ATTRIBUTES.items():
m = pat.match(selector, end)
if m is not None:
if atype == 'id':
if not found_id:
attrs[atype] = m.group(0)[1:]
end = m.end()
found_id = True
else:
raise ValueError('Only one ID is allowed')
elif atype == 'class':
if atype not in attrs:
attrs[atype] = [m.group(0)[1:]]
else:
attrs[atype].append(m.group(0)[1:])
end = m.end()
else:
results = m.group(0)
m2 = RE_ATTR.search(results)
while m2 is not None:
pos = m2.end()
name = m2.group('attr_name').lower()
value = m2.group('value')
if value is None:
value = name if name != 'class' else ''
elif value.startswith(('"', "'")):
value = value[1:-1]
if name == 'class':
value = [v for v in value.split(' ') if v]
if value:
if name in attrs:
attrs[name].extend(value)
else:
attrs[name] = value
else:
value = value
attrs[name] = value
m2 = RE_ATTR.search(results, pos)
end = m.end()
break
if m is None:
raise ValueError('Invalid selector')
if 'class' in attrs:
attrs['class'] = ' '.join(sorted(attrs['class']))
return tag, attrs
class HTML(Block):
"""
HTML.
Arguments (1 required):
- HTML tag name
Options:
- `markdown` (string): specify how content inside the element should be treated:
- `auto`: will automatically determine how an element's content should be handled.
- `inline`: treat content as an inline element's content.
- `block`: treat content as a block element's content.
- `raw`: treat the content as raw content (atomic).
Content:
HTML element content.
"""
NAME = 'html'
ARGUMENT = True
OPTIONS = {
'markdown': ('auto', type_string_in(VALID_MODES))
}
def __init__(self, length, tracker, md, config):
"""Initialize."""
self.markdown = None
self.custom = {}
for entry in config.get('custom'):
mode = entry.get('mode', 'auto')
self.custom[entry['tag']] = mode if mode in VALID_MODES else 'auto'
super().__init__(length, tracker, md, config)
def on_validate(self, parent):
"""Handle argument parsing."""
try:
self.tag, self.attr = parse_selectors(self.argument)
except ValueError:
return False
return True
def on_markdown(self):
"""Check if this is atomic."""
mode = self.options['markdown']
if mode == 'auto':
tag = self.tag.lower()
mode = self.custom.get(tag, mode)
if mode == 'html':
mode = 'raw'
return mode
def on_create(self, parent):
"""Create the element."""
# Create element
return etree.SubElement(parent, self.tag.lower(), self.attr)
def is_html(self, tag):
"""Does tag require no processing and no HTML escaping."""
return tag.tag in ('script', 'style')
def on_end(self, block):
"""On end event."""
mode = self.options['markdown']
if mode == 'auto':
tag = self.tag.lower()
mode = self.custom.get(tag, mode)
if (mode == 'auto' and self.is_html(block)) or mode == 'html':
block.text = self.md.htmlStash.store(block.text)
elif (mode == 'auto' and self.is_raw(block)) or mode == 'raw':
block.text = self.md.htmlStash.store(self.html_escape(block.text))
class HTMLExtension(BlocksExtension):
"""HTML Blocks Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
"custom": [
[],
"Specify handling for custom blocks."
]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(HTML, self.getConfigs())
def makeExtension(*args, **kwargs):
"""Return extension."""
return HTMLExtension(*args, **kwargs)
@@ -0,0 +1,303 @@
"""Tabs."""
import xml.etree.ElementTree as etree
from markdown.extensions import toc
from markdown.treeprocessors import Treeprocessor
from .block import Block, type_boolean
from ..blocks import BlocksExtension
import html
HEADERS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
class TabbedTreeprocessor(Treeprocessor):
"""Tab tree processor."""
def __init__(self, md, config):
"""Initialize."""
super().__init__(md)
self.alternate = config['alternate_style']
self.slugify = config['slugify']
self.combine_header_slug = config['combine_header_slug']
self.sep = config["separator"]
def get_parent_header_slug(self, root, header_map, parent_map, el):
"""Attempt retrieval of parent header slug."""
parent = el
last_parent = parent
while parent is not root:
last_parent = parent
parent = parent_map[parent]
if parent in header_map:
headers = header_map[parent]
header = None
for i in list(parent):
if i is el and header is None:
break
if i is last_parent and header is not None:
return header.attrib.get("id", '')
if i in headers:
header = i
return ''
def run(self, doc):
"""Update tab IDs."""
# Get a list of id attributes
used_ids = set()
parent_map = {}
header_map = {}
if self.combine_header_slug:
parent_map = {c: p for p in doc.iter() for c in p}
for el in doc.iter():
if "id" in el.attrib:
if self.combine_header_slug and el.tag in HEADERS:
parent = parent_map[el]
if parent in header_map:
header_map[parent].append(el)
else:
header_map[parent] = [el]
used_ids.add(el.attrib["id"])
for el in doc.iter():
if isinstance(el.tag, str) and el.tag.lower() == 'div':
classes = el.attrib.get('class', '').split()
if 'tabbed-set' in classes and (not self.alternate or 'tabbed-alternate' in classes):
inputs = []
labels = []
if self.alternate:
for i in list(el):
if i.tag == 'input':
inputs.append(i)
if i.tag == 'div' and i.attrib.get('class', '') == 'tabbed-labels':
labels = [j for j in list(i) if j.tag == 'label']
else:
for i in list(el):
if i.tag == 'input':
inputs.append(i)
if i.tag == 'label':
labels.append(i)
# Generate slugged IDs
for inpt, label in zip(inputs, labels):
innerhtml = toc.render_inner_html(toc.remove_fnrefs(label), self.md)
innertext = html.unescape(toc.strip_tags(innerhtml))
if self.combine_header_slug:
parent_slug = self.get_parent_header_slug(doc, header_map, parent_map, el)
else:
parent_slug = ''
slug = self.slugify(innertext, self.sep)
if parent_slug:
slug = parent_slug + self.sep + slug
slug = toc.unique(slug, used_ids)
inpt.attrib["id"] = slug
label.attrib["for"] = slug
class Tab(Block):
"""
Tabbed container.
Arguments (1 required):
- A tab title.
Options:
- `new` (boolean): since consecutive tabs are automatically grouped, `new` can force a tab
to start a new tab container.
Content:
Detail body.
"""
NAME = 'tab'
ARGUMENT = True
OPTIONS = {
'new': (False, type_boolean),
'select': (False, type_boolean)
}
def on_init(self):
"""Handle initialization."""
self.alternate_style = self.config['alternate_style']
self.slugify = callable(self.config['slugify'])
# Track tab group count across the entire page.
if 'tab_group_count' not in self.tracker:
self.tracker['tab_group_count'] = 0
self.tab_content = None
def last_child(self, parent):
"""Return the last child of an `etree` element."""
if len(parent):
return parent[-1]
else:
return None
def on_add(self, block):
"""Adjust where the content is added."""
if self.tab_content is None:
if self.alternate_style:
for d in block.findall('div'):
c = d.attrib['class']
if c == 'tabbed-content' or c.startswith('tabbed-content '):
self.tab_content = list(d)[-1]
break
else:
self.tab_content = list(block)[-1]
return self.tab_content
def on_create(self, parent):
"""Create the element."""
new_group = self.options['new']
select = self.options['select']
title = self.argument
sibling = self.last_child(parent)
tabbed_set = 'tabbed-set' if not self.alternate_style else 'tabbed-set tabbed-alternate'
index = 0
labels = None
content = None
if (
sibling is not None and sibling.tag.lower() == 'div' and
sibling.attrib.get('class', '') == tabbed_set and
not new_group
):
first = False
tab_group = sibling
if self.alternate_style:
index = [index for index, _ in enumerate(tab_group.findall('input'), 1)][-1]
for d in tab_group.findall('div'):
if d.attrib['class'] == 'tabbed-labels':
labels = d
elif d.attrib['class'] == 'tabbed-content':
content = d
if labels is not None and content is not None:
break
else:
first = True
self.tracker['tab_group_count'] += 1
tab_group = etree.SubElement(
parent,
'div',
{'class': tabbed_set, 'data-tabs': '%d:0' % self.tracker['tab_group_count']}
)
if self.alternate_style:
labels = etree.SubElement(
tab_group,
'div',
{'class': 'tabbed-labels'}
)
content = etree.SubElement(
tab_group,
'div',
{'class': 'tabbed-content'}
)
data = tab_group.attrib['data-tabs'].split(':')
tab_set = int(data[0])
tab_count = int(data[1]) + 1
attributes = {
"name": "__tabbed_%d" % tab_set,
"type": "radio"
}
if not self.slugify:
attributes['id'] = "__tabbed_%d_%d" % (tab_set, tab_count)
attributes2 = {"for": "__tabbed_%d_%d" % (tab_set, tab_count)} if not self.slugify else {}
if first or select:
attributes['checked'] = 'checked'
# Remove any previously assigned "checked states" to siblings
for i in tab_group.findall('input'):
if i.attrib.get('name', '') == f'__tabbed_{tab_set}':
if 'checked' in i.attrib:
del i.attrib['checked']
if self.alternate_style:
input_el = etree.Element(
'input',
attributes
)
tab_group.insert(index, input_el)
lab = etree.SubElement(
labels,
"label",
attributes2
)
lab.text = title
attrib = {'class': 'tabbed-block'}
etree.SubElement(
content,
"div",
attrib
)
else:
etree.SubElement(
tab_group,
'input',
attributes
)
lab = etree.SubElement(
tab_group,
"label",
attributes2
)
lab.text = title
etree.SubElement(
tab_group,
"div",
{
"class": "tabbed-content"
}
)
tab_group.attrib['data-tabs'] = '%d:%d' % (tab_set, tab_count)
return tab_group
class TabExtension(BlocksExtension):
"""Admonition Blocks Extension."""
def __init__(self, *args, **kwargs):
"""Initialize."""
self.config = {
'alternate_style': [False, "Use alternate style - Default: False"],
'slugify': [0, "Slugify function used to create tab specific IDs - Default: None"],
'combine_header_slug': [False, "Combine the tab slug with the slug of the parent header - Default: False"],
'separator': ['-', "Slug separator - Default: '-'"]
}
super().__init__(*args, **kwargs)
def extendMarkdownBlocks(self, md, block_mgr):
"""Extend Markdown blocks."""
block_mgr.register(Tab, self.getConfigs())
if callable(self.getConfig('slugify')):
slugs = TabbedTreeprocessor(md, self.getConfigs())
md.treeprocessors.register(slugs, 'tab_slugs', 4)
def makeExtension(*args, **kwargs):
"""Return extension."""
return TabExtension(*args, **kwargs)