150 lines
5.5 KiB
Python
150 lines
5.5 KiB
Python
"""
|
|
Extension for "enhanced" blockquotes.
|
|
|
|
This extension deviates from Python Markdown's original blockquote extension by:
|
|
|
|
- not grouping consecutive block quotes together.
|
|
- Allowing optional callout behavior that mimics GitHub or Obsidian.
|
|
"""
|
|
import re
|
|
import xml.etree.ElementTree as etree
|
|
from markdown.blockprocessors import BlockProcessor
|
|
from markdown.treeprocessors import Treeprocessor
|
|
from markdown import util
|
|
from markdown import Extension, Markdown
|
|
from markdown.blockparser import BlockParser
|
|
from typing import Any
|
|
|
|
|
|
class QuotesProcessor(BlockProcessor):
|
|
"""Process blockquotes."""
|
|
|
|
RE = re.compile(r'(^|\n)[ ]{0,3}>[ ]?(.*)')
|
|
RE_CALLOUT = re.compile(r'> *\[!([\w-]+(?: *\| *[\w-]+)*)]([-+])?(.*?)(?:\n|$)')
|
|
|
|
def __init__(self, parser: BlockParser, config: dict[str, Any]) -> None:
|
|
"""Initialize."""
|
|
|
|
super().__init__(parser)
|
|
self.callouts = config['callouts']
|
|
|
|
def test(self, parent: etree.Element, block: str) -> bool:
|
|
"""Test for block quote."""
|
|
|
|
return bool(self.RE.search(block)) and not util.nearing_recursion_limit()
|
|
|
|
def run(self, parent: etree.Element, blocks: list[str]) -> None:
|
|
"""Create blockquote."""
|
|
|
|
block = blocks.pop(0)
|
|
alert = []
|
|
details = ''
|
|
m = self.RE.search(block)
|
|
if m:
|
|
before = block[:m.start()] # Lines before blockquote
|
|
# Pass lines before blockquote in recursively for parsing first.
|
|
self.parser.parseBlocks(parent, [before])
|
|
# Remove `> ` from beginning of each line.
|
|
lines = block[m.start():].split('\n')
|
|
if lines and self.callouts:
|
|
m2 = None
|
|
index = 0
|
|
for line in lines:
|
|
if line and line.strip() != '>':
|
|
m2 = self.RE_CALLOUT.match(line)
|
|
break
|
|
index += 1
|
|
if m2:
|
|
alert = [x.strip() for x in m2.group(1).split('|')]
|
|
if m2.group(2):
|
|
details = 'open' if m2.group(2) == '+' else 'closed'
|
|
title = m2.group(3).strip() if m2.group(3) else ''
|
|
if not title:
|
|
title = alert[0].title()
|
|
lines[index] = ''
|
|
lines.insert(index, title)
|
|
if alert:
|
|
alert[0] = alert[0].lower()
|
|
block = '\n'.join([self.clean(l) for l in lines])
|
|
|
|
# This is a new blockquote. Create a new parent element.
|
|
attrs = {'data-alert': ' '.join(alert), 'data-alert-collapse': details} if alert else {}
|
|
quote = etree.SubElement(parent, 'blockquote', attrs)
|
|
|
|
# Recursively parse block with blockquote as parent.
|
|
# change parser state so blockquotes embedded in lists use `p` tags
|
|
self.parser.state.set('blockquote')
|
|
self.parser.parseChunk(quote, block)
|
|
self.parser.state.reset()
|
|
|
|
def clean(self, line: str) -> str:
|
|
"""Remove `>` from beginning of a line."""
|
|
|
|
m = self.RE.match(line)
|
|
if line.strip() == ">":
|
|
return ""
|
|
elif m:
|
|
return m.group(2)
|
|
else:
|
|
return line
|
|
|
|
|
|
class QuotesTreeprocessor(Treeprocessor):
|
|
"""Convert "special" quotes to the common output format for Admonitions and Details."""
|
|
|
|
def run(self, root: etree.Element) -> etree.Element:
|
|
"""Find and convert "special" blockquotes."""
|
|
|
|
for b in root.iter('blockquote'):
|
|
if b.attrib.get('data-alert'):
|
|
collapse = b.attrib.get('data-alert-collapse', '')
|
|
if collapse:
|
|
b.tag = 'details'
|
|
child = b.find('*')
|
|
if collapse == 'open':
|
|
b.attrib['open'] = 'open'
|
|
c = b.attrib.get('class', '').split(' ')
|
|
if child is not None and child.tag.lower() == 'p':
|
|
child.tag = 'summary'
|
|
else:
|
|
b.tag = 'div'
|
|
child = b.find('*')
|
|
c = b.attrib.get('class', '').split(' ')
|
|
c.append('admonition')
|
|
if child is not None and child.tag.lower() == 'p':
|
|
c2 = child.attrib.get('class', '').split(' ')
|
|
c2.append('admonition-title')
|
|
child.attrib['class'] = ' '.join(_c for _c in c2 if _c)
|
|
c.append(b.attrib.get('data-alert', ''))
|
|
b.attrib['class'] = ' '.join(_c for _c in c if _c)
|
|
del b.attrib['data-alert']
|
|
del b.attrib['data-alert-collapse']
|
|
return root
|
|
|
|
|
|
class QuotesExtension(Extension):
|
|
"""Add blockquotes extension to Markdown class."""
|
|
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
"""Initialize."""
|
|
|
|
self.config = {
|
|
'callouts': [False, "Enable GitHub/Obsidian style callouts - Default: False"]
|
|
}
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def extendMarkdown(self, md: Markdown) -> None:
|
|
"""Add support for blockquotes."""
|
|
|
|
md.registerExtension(self)
|
|
config = self.getConfigs()
|
|
md.parser.blockprocessors.register(QuotesProcessor(md.parser, config), "quote", 20)
|
|
if config['callouts']:
|
|
md.treeprocessors.register(QuotesTreeprocessor(md), 'quotes', 19.99)
|
|
|
|
|
|
def makeExtension(*args: Any, **kwargs: Any) -> Extension:
|
|
"""Return extension."""
|
|
|
|
return QuotesExtension(*args, **kwargs)
|