diff --git a/pyproject.toml b/pyproject.toml index 703d41c..06e47aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mkdocs-callouts" -version = "1.14.0" +version = "1.14.1" keywords = ["mkdocs", "mkdocs-plugin", "markdown", "callouts", "admonitions", "obsidian"] description = "A simple plugin that converts Obsidian style callouts and converts them into mkdocs supported 'admonitions' (a.k.a. callouts)." readme = "README.md" diff --git a/src/mkdocs_callouts/utils.py b/src/mkdocs_callouts/utils.py index 78818ba..4b7a096 100644 --- a/src/mkdocs_callouts/utils.py +++ b/src/mkdocs_callouts/utils.py @@ -1,13 +1,13 @@ import re -CALLOUT_BLOCK_REGEX = re.compile(r'^(\s*)((?:> ?)+) *\[!([^\]]*)\]([\-\+]?)(.*)?') +CALLOUT_BLOCK_REGEX = re.compile(r"^(\s*)((?:> ?)+) *\[!([^\]]*)\]([\-\+]?)(.*)?") # (1): leading whitespace (all tabs and 4x spaces get reused) # (2): indents (all leading '>' symbols) # (3): callout type ([!'capture'] or [!'capture | attribute'] excl. brackets and leading !) # (4): foldable token (+ or - or ) # (5): title -CALLOUT_CONTENT_SYNTAX_REGEX = re.compile(r'^(\s*)((?:> ?)+)') +CALLOUT_CONTENT_SYNTAX_REGEX = re.compile(r"^(\s*)((?:> ?)+)") # (1): leading whitespace (all tabs and 4x spaces get reused) @@ -16,18 +16,21 @@ class CalloutParser: """Class to parse callout blocks from markdown and convert them to mkdocs supported admonitions.""" + # From https://help.obsidian.md/How+to/Use+callouts#Types aliases = { - 'abstract': ['summary', 'tldr'], - 'tip': ['hint', 'important'], - 'success': ['check', 'done'], - 'question': ['help', 'faq'], - 'warning': ['caution', 'attention'], - 'failure': ['fail', 'missing'], - 'danger': ['error'], - 'quote': ['cite'] + "abstract": ["summary", "tldr"], + "tip": ["hint", "important"], + "success": ["check", "done"], + "question": ["help", "faq"], + "warning": ["caution", "attention"], + "failure": ["fail", "missing"], + "danger": ["error"], + "quote": ["cite"], } - alias_tuples = [(alias, c_type) for c_type, aliases in aliases.items() for alias in aliases] + alias_tuples = [ + (alias, c_type) for c_type, aliases in aliases.items() for alias in aliases + ] def __init__(self, convert_aliases: bool = True, breakless_lists: bool = True): # Stack to keep track of the current indentation level @@ -49,19 +52,21 @@ def _get_indent(self, indent_level: int, is_block: bool = False) -> str: """ Returns the correct indent string for the current indent level. """ - indent = '' + indent = "" # If we are parsing a block, then we should not add a tab for the last indent level (only content needs it) for i in range(indent_level - int(is_block)): # If the indent level exists in the list, add a tab (callout), otherwise add a '> ' (blockquote) - indent += '\t' if i + 1 in self.indent_levels else '> ' + indent += "\t" if i + 1 in self.indent_levels else "> " # Blockquotes use spaces instead of tabs for consistent indentation (must be 4 spaces into the callout) # When not using blockquotes, a tab should count as 4 spaces by default because of no preceding symbols # blockquote: > !!! note "Title" || blockquote: > !!! note "Title" # with \t > SSContent (wrong) || with spaces > SSSSContent (correct) - is_part_of_blockquote = '> ' in indent # indent should only be '\t' symbols if false. + is_part_of_blockquote = ( + "> " in indent + ) # indent should only be '\t' symbols if false. if is_part_of_blockquote: - indent = indent.replace('\t', ' ' * 4) + indent = indent.replace("\t", " " * 4) return indent @@ -71,38 +76,40 @@ def _parse_block_syntax(self, block) -> str: Takes an argument block, which is a regex match. """ # Group 1: Leading whitespace (we need to reuse tabs and 4x spaces) - whitespace = block.group(1).replace(' ', '\t') - whitespace = '\t' * whitespace.count('\t') # Ignore everything but tabs + whitespace = block.group(1).replace(" ", "\t") + whitespace = "\t" * whitespace.count("\t") # Ignore everything but tabs # Group 2: Leading > symbols (indentation, for nested callouts) - indent_level = block.group(2).count('>') - indent = f'{whitespace}{self._get_indent(indent_level=indent_level, is_block=True)}' + indent_level = block.group(2).count(">") + indent = ( + f"{whitespace}{self._get_indent(indent_level=indent_level, is_block=True)}" + ) # Group 3: Callout block type (note, warning, info, etc.) + inline block syntax c_type = block.group(3).lower() - c_type = re.sub(r' *\| *(inline|left) *$', ' inline', c_type) - c_type = re.sub(r' *\| *(inline end|right) *$', ' inline end', c_type) - c_type = re.sub(r' *\|.*', '', c_type) + c_type = re.sub(r" *\| *(inline|left) *$", " inline", c_type) + c_type = re.sub(r" *\| *(inline end|right) *$", " inline end", c_type) + c_type = re.sub(r" *\|.*", "", c_type) # Convert aliases, if enabled if self.convert_aliases: c_type = self._convert_aliases(c_type) # Group 4: Foldable callouts - syntax = {'-': '???', '+': '???+'} - syntax = syntax.get(block.group(4), '!!!') + syntax = {"-": "???", "+": "???+"} + syntax = syntax.get(block.group(4), "!!!") # Group 5: Title, add leading whitespace and quotation marks, if it exists title = block.group(5).strip() - title = f' "{title}"' if title else '' + title = f' "{title}"' if title else "" # Construct the new callout syntax ({indent}!!! note "Title") - return f'{indent}{syntax} {c_type}{title}' + return f"{indent}{syntax} {c_type}{title}" @staticmethod def _convert_aliases(c_type: str) -> str: """Converts aliases to their respective callout type, if its enabled""" for alias, identifier in CalloutParser.alias_tuples: - c_type = re.sub(rf'^{alias}\b', identifier, c_type) + c_type = re.sub(rf"^{alias}\b", identifier, c_type) return c_type def _breakless_list_handler(self, line: str) -> str: @@ -112,17 +119,17 @@ def _breakless_list_handler(self, line: str) -> str: This is a workaround for Obsidian's default behavior, which allows for lists to be created without a blank line between them. """ - is_list = re.search(r'^\s*(?:[-+*]|\d+\.)\s', line) + is_list = re.search(r"^\s*(?:[-+*]|\d+\.)\s", line) if is_list and self.text_in_prev_line: # If the previous line was a list, keep the line as is if self.list_in_prev_line: return line # If the previous line was text, add a newline before the list - indent = re.search(r'^\t*', line).group() - line = f'{indent}\n{line}' + indent = re.search(r"^\t*", line).group() + line = f"{indent}\n{line}" else: # Set text_in_prev_line according to the current line - self.text_in_prev_line = line.strip() != '' + self.text_in_prev_line = line.strip() != "" self.list_in_prev_line = is_list return line @@ -131,7 +138,7 @@ def _convert_block(self, line: str) -> str: match = re.search(CALLOUT_BLOCK_REGEX, line) if match: # Store the current indent level and add it to the list if it doesn't exist - indent_level = match.group(2).count('>') + indent_level = match.group(2).count(">") # If the indent level is not in the indent levels, add it to the list if indent_level not in self.indent_levels: @@ -156,13 +163,13 @@ def _convert_content(self, line: str) -> str: match = re.search(CALLOUT_CONTENT_SYNTAX_REGEX, line) if match and self.indent_levels: # Group 1: Leading whitespace (we need to reuse tabs and 4x spaces) - whitespace = match.group(1).replace(' ', '\t') - whitespace = '\t' * whitespace.count('\t') # Ignore everything but tabs + whitespace = match.group(1).replace(" ", "\t") + whitespace = "\t" * whitespace.count("\t") # Ignore everything but tabs # Remove any higher level indents compared to the current indent level # i.e. if we are at 1, and indent_levels is [1, 2, 3], remove 2 and 3 try: - while match.group(2).count('>') < self.indent_levels[-1]: + while match.group(2).count(">") < self.indent_levels[-1]: self.indent_levels = self.indent_levels[:-1] except IndexError: # If the indent levels list is empty, reset the states and return the line @@ -171,9 +178,11 @@ def _convert_content(self, line: str) -> str: return line # Construct the new indent level - indent = f'{whitespace}{self._get_indent(indent_level=max(self.indent_levels))}' + indent = ( + f"{whitespace}{self._get_indent(indent_level=max(self.indent_levels))}" + ) - line = re.sub(rf'^\s*(?:> ?){{{self.indent_levels[-1]}}} ?', indent, line) + line = re.sub(rf"^\s*(?:> ?){{{self.indent_levels[-1]}}}", indent, line) # Handle breakless lists before returning the line, if enabled if self.breakless_lists: @@ -207,8 +216,8 @@ def convert_line(self, line: str) -> str: """ # Toggle codefence indices if we encounter a codefence # (If a line starts with '```' before any meaningful content, it's a codefence) - if re.match(r'^\s*(?:>\s*)*```', line): - self._toggle_codefence_at_index(line.index('```')) + if re.match(r"^\s*(?:>\s*)*```", line): + self._toggle_codefence_at_index(line.index("```")) # Codefences get treated like content (because they could contain callouts inside them that should not convert) if self.in_codefence and self.indent_levels: return self._convert_content(line) @@ -220,7 +229,7 @@ def parse(self, markdown: str) -> str: """Takes a markdown file input returns a version with converted callout syntax""" self.indent_levels = list() # Reset (redundant in conjunction with mkdocs) # If markdown file does not contain a callout, skip it - if not re.search(r'> *\[!', markdown): + if not re.search(r"> *\[!", markdown): return markdown # Convert markdown line by line, then return it - return '\n'.join(self.convert_line(line) for line in markdown.split('\n')) + return "\n".join(self.convert_line(line) for line in markdown.split("\n"))