Skip to content

Commit

Permalink
refactor: reorganize to match mdformat-admon design and test rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
KyleKing committed Mar 10, 2024
1 parent 5d0649c commit 3bd6a3d
Show file tree
Hide file tree
Showing 8 changed files with 255 additions and 141 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ pipx install mdformat
pipx inject mdformat mdformat-gfm-alerts
```

## HTML Rendering

To generate HTML output, `gfm_alerts_plugin` can be imported from `mdit_plugins`. For more guidance on `MarkdownIt`, see the docs: <https://markdown-it-py.readthedocs.io/en/latest/using.html#the-parser>

```py
from markdown_it import MarkdownIt

from mdformat_gfm_alerts.mdit_plugins import gfm_alerts_plugin

md = MarkdownIt()
md.use(gfm_alerts_plugin)

text = "> [!NOTE]\n> Useful information that users should know, even when skimming content. "
md.render(text)
# <blockquote>
# <div class="admonition note">
# <p>Useful information that users should know, even when skimming content.</p>
# </div>
# </blockquote>
```

## Contributing

See [CONTRIBUTING.md](https://github.com/KyleKing/mdformat-gfm-alerts/blob/main/CONTRIBUTING.md)
Expand Down
6 changes: 6 additions & 0 deletions mdformat_gfm_alerts/factories/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from ._factories import (
AlertData,
gfm_alert_plugin_factory,
new_token,
parse_possible_blockquote_admon_factory,
)
106 changes: 106 additions & 0 deletions mdformat_gfm_alerts/factories/_factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""GitHub Alerts."""

from __future__ import annotations

import re
from collections.abc import Generator
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, NamedTuple

from markdown_it import MarkdownIt
from markdown_it.rules_block import StateBlock
from mdit_py_plugins.utils import is_code_block

if TYPE_CHECKING:
from markdown_it.token import Token

PREFIX = "gfm_alert"
"""Prefix used to differentiate the parsed output."""


# FYI: copied from mdformat_admon.factories
@contextmanager
def new_token(state: StateBlock, name: str, kind: str) -> Generator[Token, None, None]:
"""Creates scoped token."""
yield state.push(f"{name}_open", kind, 1)
state.push(f"{name}_close", kind, -1)


# FYI: Adapted from mdformat_admon.factories._factories
class AlertState(NamedTuple):
"""Frozen state."""

parentType: str
lineMax: int


class AlertData(NamedTuple):
"""AlertData data for rendering."""

old_state: AlertState
meta_text: str
inline_content: str
next_line: int


def parse_possible_blockquote_admon_factory(
patterns: set[str],
) -> Callable[[StateBlock, int, int, bool], AlertData | bool]:
"""Generate the parser function.
Accepts set of strings that will be compiled into regular expressions.
They must have a capture group `title`.
"""

def parse_possible_blockquote_admon(
state: StateBlock,
start_line: int,
end_line: int,
silent: bool,
) -> AlertData | bool:
if is_code_block(state, start_line):
return False

start = state.bMarks[start_line] + state.tShift[start_line]

# Exit if no match for any pattern
text = state.src[start:]
regexes = [
re.compile(rf"{pat}(?P<inline_content>(?: |<br>)[^\n]+)?")
for pat in patterns
]
match = next((_m for rx in regexes if (_m := rx.match(text))), None)
if not match:
return False

# Since start is found, we can report success here in validation mode
if silent:
return True

old_state = AlertState(
parentType=state.parentType,
lineMax=state.lineMax,
)
state.parentType = "gfm_alert"

return AlertData(
old_state=old_state,
meta_text=match["title"],
inline_content=match["inline_content"] or "",
next_line=end_line,
)

return parse_possible_blockquote_admon


def gfm_alert_plugin_factory(
prefix: str,
logic: Callable[[StateBlock, int, int, bool], bool],
) -> Callable[[MarkdownIt], None]:
"""Generate the plugin function."""

def gfm_alert_plugin(md: MarkdownIt) -> None:
md.block.ruler.before("fence", prefix, logic)

return gfm_alert_plugin
1 change: 1 addition & 0 deletions mdformat_gfm_alerts/mdit_plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from ._gfm_alert import format_gfm_alert_markup, gfm_alert_plugin
61 changes: 61 additions & 0 deletions mdformat_gfm_alerts/mdit_plugins/_gfm_alert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""GitHub Alerts."""

from __future__ import annotations

from markdown_it.rules_block import StateBlock

from ..factories import (
AlertData,
gfm_alert_plugin_factory,
new_token,
parse_possible_blockquote_admon_factory,
)

PREFIX = "gfm_alert"
"""Prefix used to differentiate the parsed output."""

PATTERNS = {
# Note '> ' prefix is removed when parsing
r"^\*\*(?P<title>Note|Warning)\*\*",
r"^\\?\[!(?P<title>NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\?\]",
}
"""Patterns specific to GitHub Alerts."""


def format_gfm_alert_markup(
state: StateBlock,
start_line: int,
admonition: AlertData,
) -> None:
"""Format markup."""
with new_token(state, PREFIX, "div") as token:
token.block = True
token.attrs = {"class": f"admonition {admonition.meta_text.lower()}"}
token.info = f"[!{admonition.meta_text.upper()}]{admonition.inline_content}"
token.map = [start_line, admonition.next_line]

state.md.block.tokenize(state, start_line + 1, admonition.next_line)

# Render as a div for accessibility rather than block quote
# Which is because '>' is being misused (https://github.com/orgs/community/discussions/16925#discussioncomment-8729846)
state.parentType = "div"
state.lineMax = admonition.old_state.lineMax
state.line = admonition.next_line


def alert_logic(
state: StateBlock,
startLine: int,
endLine: int,
silent: bool,
) -> bool:
"""Parse GitHub Alerts."""
parser_func = parse_possible_blockquote_admon_factory(PATTERNS)
result = parser_func(state, startLine, endLine, silent)
if isinstance(result, AlertData):
format_gfm_alert_markup(state, startLine, admonition=result)
return True
return result


gfm_alert_plugin = gfm_alert_plugin_factory(PREFIX, alert_logic)
145 changes: 4 additions & 141 deletions mdformat_gfm_alerts/plugin.py
Original file line number Diff line number Diff line change
@@ -1,156 +1,19 @@
"""Public Plugin."""
"""Public Extension."""

from __future__ import annotations

import re
from collections.abc import Generator
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, Mapping, NamedTuple
from typing import Mapping

from markdown_it import MarkdownIt
from markdown_it.rules_block import StateBlock
from mdformat.renderer import RenderContext, RenderTreeNode
from mdformat.renderer.typing import Render
from mdit_py_plugins.utils import is_code_block

if TYPE_CHECKING:
from markdown_it.token import Token

PREFIX = "gfm_alert"
"""Prefix used to differentiate the parsed output."""

PATTERNS = {
# Note '> ' prefix is removed when parsing
r"^\*\*(?P<title>Note|Warning)\*\*",
r"^\\?\[!(?P<title>NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\?\]",
}
"""Patterns specific to GitHub Alerts."""


class AdmonState(NamedTuple):
"""Frozen state."""

parentType: str
lineMax: int


class AdmonitionData(NamedTuple):
"""AdmonitionData data for rendering."""

old_state: AdmonState
meta_text: str
inline_content: str
next_line: int


def parse_possible_blockquote_admon_factory(
patterns: set[str],
) -> Callable[[StateBlock, int, int, bool], AdmonitionData | bool]:
"""Generate the parser function.
Accepts set of strings that will be compiled into regular expressions.
They must have a capture group `title`.
"""

def parse_possible_blockquote_admon(
state: StateBlock,
start_line: int,
end_line: int,
silent: bool,
) -> AdmonitionData | bool:
if is_code_block(state, start_line):
return False

start = state.bMarks[start_line] + state.tShift[start_line]

# Exit if no match for any pattern
text = state.src[start:]
regexes = [
re.compile(rf"{pat}(?P<inline_content>(?: |<br>)[^\n]+)?")
for pat in patterns
]
match = next((_m for rx in regexes if (_m := rx.match(text))), None)
if not match:
return False

# Since start is found, we can report success here in validation mode
if silent:
return True

old_state = AdmonState(
parentType=state.parentType,
lineMax=state.lineMax,
)
state.parentType = "gfm_alert"

return AdmonitionData(
old_state=old_state,
meta_text=match["title"],
inline_content=match["inline_content"] or "",
next_line=end_line,
)

return parse_possible_blockquote_admon


# FYI: copied from mdformat_admon.factories
@contextmanager
def new_token(state: StateBlock, name: str, kind: str) -> Generator[Token, None, None]:
"""Creates scoped token."""
yield state.push(f"{name}_open", kind, 1)
state.push(f"{name}_close", kind, -1)


def format_gfm_alert_markup(
state: StateBlock,
start_line: int,
admonition: AdmonitionData,
) -> None:
"""Format markup."""
with new_token(state, PREFIX, "div") as token:
token.block = True
token.attrs = {"class": "admonition"}
token.info = f"[!{admonition.meta_text.upper()}]{admonition.inline_content}"
token.map = [start_line, admonition.next_line]

state.md.block.tokenize(state, start_line + 1, admonition.next_line)

state.parentType = admonition.old_state.parentType
state.lineMax = admonition.old_state.lineMax
state.line = admonition.next_line


def alert_logic(
state: StateBlock,
startLine: int,
endLine: int,
silent: bool,
) -> bool:
"""Parse GitHub Alerts."""
parser_func = parse_possible_blockquote_admon_factory(PATTERNS)
result = parser_func(state, startLine, endLine, silent)
if isinstance(result, AdmonitionData):
format_gfm_alert_markup(state, startLine, admonition=result)
return True
return result


def gfm_alert_plugin_factory(
prefix: str,
logic: Callable[[StateBlock, int, int, bool], bool],
) -> Callable[[MarkdownIt], None]:
"""Generate the plugin function."""

def gfm_alert_plugin(md: MarkdownIt) -> None:
md.block.ruler.before("fence", prefix, logic)

return gfm_alert_plugin
from .mdit_plugins import gfm_alert_plugin


def update_mdit(mdit: MarkdownIt) -> None:
"""Update the parser to identify Alerts."""
mdit.use(gfm_alert_plugin_factory("gfm_alert", alert_logic))
mdit.use(gfm_alert_plugin)


def _render_gfm_alert(node: RenderTreeNode, context: RenderContext) -> str:
Expand Down
23 changes: 23 additions & 0 deletions tests/render/fixtures/gfm_alerts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
2023 Syntax
.
> [!NOTE]
> Useful information that users should know, even when skimming content.
.
<blockquote>
<div class="admonition note">
<p>Useful information that users should know, even when skimming content.</p>
</div>
</blockquote>
.

Replaces 2022 with 2023 Syntax
.
> **Warning**
> This is a warning
.
<blockquote>
<div class="admonition warning">
<p>This is a warning</p>
</div>
</blockquote>
.
Loading

0 comments on commit 3bd6a3d

Please sign in to comment.