Skip to content

Commit

Permalink
Merge pull request #8 from sondregronas/feature/improved-nested-callo…
Browse files Browse the repository at this point in the history
…ut-handling

Better nested callout handling, supports spaces after
  • Loading branch information
sondregronas authored Feb 22, 2024
2 parents c7613d3 + fd9d976 commit db6dec5
Show file tree
Hide file tree
Showing 5 changed files with 121 additions and 15 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,12 @@ plugins:
- callouts:
aliases: false
```

### Breakless lists (New in 1.11.0)
Markdown specification requires a blank line between list items and other block elements, whereas Obsidian does not require this. This plugin will by default automatically add a blank line between list items and callout blocks (if none are present). Should you wish to disable this behaviour then you can do so by setting `breakless_lists` to `false` in the plugin configuration:
```yaml
plugins:
- search
- callouts:
breakless_lists: false
```
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "mkdocs-callouts"
version = "1.10.0"
version = "1.11.0"
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"
Expand Down
6 changes: 4 additions & 2 deletions src/mkdocs_callouts/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ class CalloutsPlugin(BasePlugin):
with confidence using Obsidian.
"""
config_scheme = { # pragma: no cover
('aliases', config_options.Type(bool, default=True))
('aliases', config_options.Type(bool, default=True)),
('breakless_lists', config_options.Type(bool, default=True))
}

def on_page_markdown(self, markdown, page, config, files):
parser = CalloutParser(
convert_aliases=self.config.get('aliases', True)
convert_aliases=self.config.get('aliases', True),
breakless_lists=self.config.get('breakless_lists', True)
)
return parser.parse(markdown)
64 changes: 53 additions & 11 deletions src/mkdocs_callouts/utils.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import re

CALLOUT_BLOCK_REGEX = re.compile(r'^ ?(>+) *\[!([^\]]*)\]([\-\+]?)(.*)?')
CALLOUT_BLOCK_REGEX = re.compile(r'^ ?((?:> ?)+) *\[!([^\]]*)\]([\-\+]?)(.*)?')
# (1): indents (all leading '>' symbols)
# (2): callout type ([!'capture'] or [!'capture | attribute'] excl. brackets and leading !)
# (3): foldable token (+ or - or <blank>)
# (4): title

CALLOUT_CONTENT_SYNTAX_REGEX = re.compile(r'^ ?(>+) ?')
CALLOUT_CONTENT_SYNTAX_REGEX = re.compile(r'^ ?((?:> ?)+) ?')
# (1): indents (all leading '>' symbols)


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'],
Expand All @@ -24,12 +25,22 @@ class CalloutParser:
}
alias_tuples = [(alias, c_type) for c_type, aliases in aliases.items() for alias in aliases]

def __init__(self, convert_aliases: bool = True):
self.active_callout: bool = False
def __init__(self, convert_aliases: bool = True, breakless_lists: bool = True):
# Stack to keep track of the current indentation level
self.indent_levels: list[int] = list()
# Whether to convert aliases or not
self.convert_aliases: bool = convert_aliases

# Breakless list allow for lists to be created without a blank line between them
# (Obsidian's default behavior, but not within the scope of the CommonMark spec)
self.breakless_lists: bool = breakless_lists
# Flags to keep track of the previous line's content for breakless list handling
self.text_in_prev_line: bool = False
self.list_in_prev_line: bool = False

def _parse_block_syntax(self, block) -> str:
"""Converts the callout syntax from obsidian into the mkdocs syntax
"""
Converts the callout syntax from obsidian into the mkdocs syntax
Takes an argument block, which is a regex match.
"""
# Group 1: Leading > symbols (indentation, for nested callouts)
Expand Down Expand Up @@ -63,11 +74,35 @@ def _convert_aliases(c_type: str) -> str:
c_type = re.sub(rf'^{alias}\b', identifier, c_type)
return c_type

def _breakless_list_handler(self, line: str) -> str:
"""
Handles a breakless list by adding a newline if the previous line was text
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*[-+*]\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}'
else:
# Set text_in_prev_line according to the current line
self.text_in_prev_line = line.strip() != ''
self.list_in_prev_line = is_list
return line

def _convert_block(self, line: str) -> str:
"""Calls parse_block_syntax if regex matches, which returns a converted callout block"""
match = re.search(CALLOUT_BLOCK_REGEX, line)
if match:
self.active_callout = True
# Store the current indent level and add it to the list if it doesn't exist
indent_level = match.group(1).count('>')
if indent_level not in self.indent_levels:
self.indent_levels.append(indent_level)
return self._parse_block_syntax(match)

def _convert_content(self, line: str) -> str:
Expand All @@ -77,11 +112,18 @@ def _convert_content(self, line: str) -> str:
Will return the original line if active_callout is false or if line is missing leading '>' symbols.
"""
match = re.search(CALLOUT_CONTENT_SYNTAX_REGEX, line)
if match and self.active_callout:
indent = '\t' * match.group(1).count('>')
line = re.sub(CALLOUT_CONTENT_SYNTAX_REGEX, indent, line)
if match and self.indent_levels:
# Get the last indent level and remove any higher levels when the current line
# has a lower indent level than the last line.
if match.group(1).count('>') < self.indent_levels[-1]:
self.indent_levels = self.indent_levels[:-1]
indent = '\t' * self.indent_levels[-1]
line = re.sub(rf'^ ?(?:> ?){{{self.indent_levels[-1]}}} ?', indent, line)
else:
self.active_callout = False
self.indent_levels = list()
# Handle breakless lists before returning the line, if enabled
if self.breakless_lists:
line = self._breakless_list_handler(line)
return line

def convert_line(self, line: str) -> str:
Expand All @@ -94,7 +136,7 @@ def convert_line(self, line: str) -> str:

def parse(self, markdown: str) -> str:
"""Takes a markdown file input returns a version with converted callout syntax"""
self.active_callout = False # Reset (redundant in conjunction with mkdocs)
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):
return markdown
Expand Down
55 changes: 54 additions & 1 deletion tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,57 @@ def test_aliases_disabled():
unexpected = '!!! tip\n\tText'
result = '!!! hint\n\tText'
assert (parser.parse(mkdown) == result)
assert (parser.parse(mkdown) != unexpected)
assert (parser.parse(mkdown) != unexpected)


def test_nested_callouts_with_spaces():
parser = CalloutParser(convert_aliases=True)

mkdown = '> [!INFO]\n> > [!INFO]'
result = '!!! info\n\t!!! info'
assert (parser.parse(mkdown) == result)


mkdown = '> [!INFO]\n> > [!INFO] Title\n> > > [!INFO]\n> [!INFO]\n > [!INFO]'
result = '!!! info\n\t!!! info "Title"\n\t\t!!! info\n!!! info\n!!! info'
assert (parser.parse(mkdown) == result)


def test_nested_callouts_with_blockquotes():
parser = CalloutParser(convert_aliases=True)

mkdown = '> [!INFO]\n> > blockquote'
result = '!!! info\n\t> blockquote'
assert (parser.parse(mkdown) == result)

mkdown = '> [!INFO]\n> > blockquote\n> > > blockquote'
result = '!!! info\n\t> blockquote\n\t> > blockquote'
assert (parser.parse(mkdown) == result)

mkdown = '> [!INFO]\n> > [!INFO]\n> text\n> > blockquote'
result = '!!! info\n\t!!! info\n\ttext\n\t> blockquote'
assert (parser.parse(mkdown) == result)


def test_breakless_lists():
parser = CalloutParser(convert_aliases=True, breakless_lists=False)

mkdown = '> [!INFO]\n> text\n> - item 1\n> - item 2'
result = '!!! info\n\ttext\n\t- item 1\n\t- item 2'
assert (parser.parse(mkdown) == result)

parser = CalloutParser(convert_aliases=True, breakless_lists=True)

mkdown = '> [!INFO]\n> text\n> - item 1\n> - item 2'
result = '!!! info\n\ttext\n\t\n\t- item 1\n\t- item 2'
assert (parser.parse(mkdown) == result)

# Shouldn't interfere with standard lists following the correct syntax
mkdown = '> [!INFO]\n>\n> - item 1\n> - item 2\n> text'
result = '!!! info\n\t\n\t- item 1\n\t- item 2\n\ttext'
assert (parser.parse(mkdown) == result)

# Nested callouts
mkdown = '> [!INFO]\n> > [!INFO]\n> > text\n> > - item 1\n> > - item 2\n> text\n> - item 1\n> - item 2'
result = '!!! info\n\t!!! info\n\t\ttext\n\t\t\n\t\t- item 1\n\t\t- item 2\n\ttext\n\t\n\t- item 1\n\t- item 2'
assert (parser.parse(mkdown) == result)

0 comments on commit db6dec5

Please sign in to comment.