test bkp
This commit is contained in:
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."""
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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('&', '&')
|
||||
text = text.replace('<', '<')
|
||||
text = text.replace('>', '>')
|
||||
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)
|
||||
Reference in new issue
Block a user