Skip to content

Commit 13397dd

Browse files
authored
Merge pull request #18 from oprypin/gh
Implement GitHub-style syntax
2 parents 11f3e2c + e89148e commit 13397dd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+743
-159
lines changed

markdown_callouts/__init__.py

Lines changed: 5 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,160 +1,11 @@
11
from __future__ import annotations
22

3-
import re
4-
import xml.etree.ElementTree as etree
5-
6-
from markdown import Markdown, util
7-
from markdown.blockprocessors import BlockQuoteProcessor
8-
from markdown.extensions import Extension
9-
from markdown.treeprocessors import Treeprocessor
10-
113
__version__ = "0.3.0"
124

135

14-
# Based on https://github.com/Python-Markdown/markdown/blob/4acb949256adc535d6e6cd84c4fb47db8dda2f46/markdown/blockprocessors.py#L277
15-
class _CalloutsBlockProcessor(BlockQuoteProcessor):
16-
REGEX = re.compile(r"(^ {0,3}>([!?])? ?|\A)([A-Z]{2,}):([ \n])(.*)", flags=re.M)
17-
18-
def test(self, parent: etree.Element, block: str) -> bool:
19-
m = self.REGEX.search(block)
20-
return (
21-
m is not None
22-
and (m[1] or not self.parser.state.isstate("blockquote"))
23-
and not util.nearing_recursion_limit() # type: ignore
24-
)
25-
26-
def run(self, parent: etree.Element, blocks: list[str]) -> None:
27-
block = blocks.pop(0)
28-
m = self.REGEX.search(block)
29-
if not m:
30-
return
31-
32-
before = block[: m.start()]
33-
self.parser.parseBlocks(parent, [before])
34-
block = block[m.start(5) :]
35-
if m[1]:
36-
block = "\n".join(self.clean(line) for line in block.split("\n"))
37-
kind = m[3]
38-
39-
if m[2]:
40-
admon = etree.SubElement(parent, "details", {"class": kind.lower()})
41-
if m[2] == "!":
42-
admon.set("open", "open")
43-
else:
44-
admon = etree.SubElement(parent, "details", {"class": "admonition " + kind.lower()})
45-
title = etree.SubElement(admon, "summary", {"class": "admonition-title"})
46-
title.text = kind.title()
47-
48-
self.parser.state.set("blockquote")
49-
self.parser.parseChunk(admon, block)
50-
self.parser.state.reset()
51-
52-
if m[4] == "\n":
53-
admon[1].text = "\n" + (admon[1].text or "")
54-
55-
56-
class _CalloutsTreeprocessor(Treeprocessor):
57-
def __init__(self, strip_period: bool) -> None:
58-
super().__init__()
59-
self.strip_period = strip_period
60-
61-
def run(self, doc: etree.Element):
62-
for root in doc.iter("details"):
63-
# Expecting this:
64-
# <details class="admonition note">
65-
# <summary class="admonition-title">Note</summary>
66-
# <p><strong>Custom title.</strong> Body</p>
67-
# </details>
68-
# And turning it into this:
69-
# <details class="note">
70-
# <summary>Custom title</summary>
71-
# <p>Body</p>
72-
# </div>
73-
# Or this:
74-
# <div class="admonition note">
75-
# <p class="admonition-title">Custom title</p>
76-
# <p>Body</p>
77-
# </div>
78-
if not root.get("class"):
79-
continue
80-
title = root[0]
81-
if title.tag != "summary" or title.get("class") != "admonition-title":
82-
continue
83-
84-
# Change <details> back to <div> if it was a normal admonition.
85-
if root.get("class", "").startswith("admonition "):
86-
root.tag = "div"
87-
title.tag = "p"
88-
else:
89-
title.attrib.pop("class", None)
90-
91-
# Find the first paragraph and a <strong> element in it.
92-
if len(root) < 2:
93-
continue
94-
paragraph = root[1]
95-
if (
96-
paragraph.tag != "p"
97-
or not len(paragraph)
98-
or (paragraph.text and paragraph.text.strip())
99-
):
100-
continue
101-
strong = paragraph[0]
102-
if strong.tag != "strong":
103-
continue
104-
if paragraph.text == "\n":
105-
continue
106-
107-
# Move everything from the bold element into the title.
108-
title.text = strong.text and strong.text.lstrip()
109-
title[:] = strong
110-
# Remove last dot at the end of the text (which might instead be the last child's tail).
111-
if len(title): # Has any child elements
112-
last = title[-1]
113-
if last.tail:
114-
last.tail = last.tail.rstrip()
115-
if self.strip_period and last.tail.endswith("."):
116-
last.tail = last.tail[:-1]
117-
else:
118-
if title.text:
119-
title.text = title.text.rstrip()
120-
if self.strip_period and title.text.endswith("."):
121-
title.text = title.text[:-1]
122-
# Make sure any text immediately following the bold element isn't lost.
123-
if strong.tail:
124-
paragraph.text = (paragraph.text or "") + strong.tail
125-
# Finally, remove the original element, also drop a possible linebreak afterwards.
126-
paragraph.remove(strong)
127-
if len(paragraph) and not paragraph.text:
128-
br = paragraph[0]
129-
if br.tag == "br":
130-
paragraph.text = br.tail
131-
paragraph.remove(br)
132-
if not len(paragraph) and not paragraph.text and not paragraph.tail:
133-
root.remove(paragraph)
134-
135-
136-
class CalloutsExtension(Extension):
137-
def __init__(self, **kwargs) -> None:
138-
self.config = {
139-
"strip_period": [
140-
True,
141-
"Remove the period (dot '.') at the end of custom titles - Default: True",
142-
],
143-
}
144-
super().__init__(**kwargs)
145-
146-
def extendMarkdown(self, md: Markdown) -> None:
147-
parser = md.parser # type: ignore
148-
parser.blockprocessors.register(
149-
_CalloutsBlockProcessor(parser),
150-
"callouts",
151-
21, # Right before blockquote
152-
)
153-
md.treeprocessors.register(
154-
_CalloutsTreeprocessor(self.getConfig("strip_period")),
155-
"callouts",
156-
19, # Right after inline
157-
)
158-
6+
def __getattr__(name: str):
7+
if name in {"CalloutsExtension", "makeExtension"}:
8+
from . import callouts
1599

160-
makeExtension = CalloutsExtension
10+
return getattr(callouts, name)
11+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

markdown_callouts/callouts.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from __future__ import annotations
2+
3+
import re
4+
import xml.etree.ElementTree as etree
5+
6+
from markdown import Markdown, util
7+
from markdown.blockprocessors import BlockQuoteProcessor
8+
from markdown.extensions import Extension
9+
from markdown.treeprocessors import Treeprocessor
10+
11+
12+
# Based on https://github.com/Python-Markdown/markdown/blob/4acb949256adc535d6e6cd84c4fb47db8dda2f46/markdown/blockprocessors.py#L277
13+
class _CalloutsBlockProcessor(BlockQuoteProcessor):
14+
REGEX = re.compile(r"(^ {0,3}>([!?])? ?|\A)([A-Z]{2,}):([ \n])(.*)", flags=re.M)
15+
16+
def test(self, parent: etree.Element, block: str) -> bool:
17+
m = self.REGEX.search(block)
18+
return (
19+
m is not None
20+
and (m[1] or not self.parser.state.isstate("blockquote"))
21+
and not util.nearing_recursion_limit() # type: ignore
22+
)
23+
24+
def run(self, parent: etree.Element, blocks: list[str]) -> None:
25+
block = blocks.pop(0)
26+
m = self.REGEX.search(block)
27+
if not m:
28+
return
29+
30+
before = block[: m.start()]
31+
self.parser.parseBlocks(parent, [before])
32+
block = block[m.start(5) :]
33+
if m[1]:
34+
block = "\n".join(self.clean(line) for line in block.split("\n"))
35+
kind = m[3]
36+
37+
if m[2]:
38+
admon = etree.SubElement(parent, "details", {"class": kind.lower()})
39+
if m[2] == "!":
40+
admon.set("open", "open")
41+
else:
42+
admon = etree.SubElement(parent, "details", {"class": "admonition " + kind.lower()})
43+
title = etree.SubElement(admon, "summary", {"class": "admonition-title"})
44+
title.text = kind.title()
45+
46+
self.parser.state.set("blockquote")
47+
self.parser.parseChunk(admon, block)
48+
self.parser.state.reset()
49+
50+
if m[4] == "\n":
51+
admon[1].text = "\n" + (admon[1].text or "")
52+
53+
54+
class _CalloutsTreeprocessor(Treeprocessor):
55+
def __init__(self, strip_period: bool) -> None:
56+
super().__init__()
57+
self.strip_period = strip_period
58+
59+
def run(self, doc: etree.Element):
60+
for root in doc.iter("details"):
61+
# Expecting this:
62+
# <details class="admonition note">
63+
# <summary class="admonition-title">Note</summary>
64+
# <p><strong>Custom title.</strong> Body</p>
65+
# </details>
66+
# And turning it into this:
67+
# <details class="note">
68+
# <summary>Custom title</summary>
69+
# <p>Body</p>
70+
# </div>
71+
# Or this:
72+
# <div class="admonition note">
73+
# <p class="admonition-title">Custom title</p>
74+
# <p>Body</p>
75+
# </div>
76+
if not root.get("class"):
77+
continue
78+
title = root[0]
79+
if title.tag != "summary" or title.get("class") != "admonition-title":
80+
continue
81+
82+
# Change <details> back to <div> if it was a normal admonition.
83+
if root.get("class", "").startswith("admonition "):
84+
root.tag = "div"
85+
title.tag = "p"
86+
else:
87+
title.attrib.pop("class", None)
88+
89+
# Find the first paragraph and a <strong> element in it.
90+
if len(root) < 2:
91+
continue
92+
paragraph = root[1]
93+
if (
94+
paragraph.tag != "p"
95+
or not len(paragraph)
96+
or (paragraph.text and paragraph.text.strip())
97+
):
98+
continue
99+
strong = paragraph[0]
100+
if strong.tag != "strong":
101+
continue
102+
if paragraph.text == "\n":
103+
continue
104+
105+
# Move everything from the bold element into the title.
106+
title.text = strong.text and strong.text.lstrip()
107+
title[:] = strong
108+
# Remove last dot at the end of the text (which might instead be the last child's tail).
109+
if len(title): # Has any child elements
110+
last = title[-1]
111+
if last.tail:
112+
last.tail = last.tail.rstrip()
113+
if self.strip_period and last.tail.endswith("."):
114+
last.tail = last.tail[:-1]
115+
else:
116+
if title.text:
117+
title.text = title.text.rstrip()
118+
if self.strip_period and title.text.endswith("."):
119+
title.text = title.text[:-1]
120+
# Make sure any text immediately following the bold element isn't lost.
121+
if strong.tail:
122+
paragraph.text = (paragraph.text or "") + strong.tail
123+
# Finally, remove the original element, also drop a possible linebreak afterwards.
124+
paragraph.remove(strong)
125+
if len(paragraph) and not paragraph.text:
126+
br = paragraph[0]
127+
if br.tag == "br":
128+
paragraph.text = br.tail
129+
paragraph.remove(br)
130+
if not len(paragraph) and not paragraph.text and not paragraph.tail:
131+
root.remove(paragraph)
132+
133+
134+
class CalloutsExtension(Extension):
135+
def __init__(self, **kwargs) -> None:
136+
self.config = {
137+
"strip_period": [
138+
True,
139+
"Remove the period (dot '.') at the end of custom titles - Default: True",
140+
],
141+
}
142+
super().__init__(**kwargs)
143+
144+
def extendMarkdown(self, md: Markdown) -> None:
145+
parser = md.parser # type: ignore
146+
parser.blockprocessors.register(
147+
_CalloutsBlockProcessor(parser),
148+
"callouts",
149+
21, # Right before blockquote
150+
)
151+
md.treeprocessors.register(
152+
_CalloutsTreeprocessor(self.getConfig("strip_period")),
153+
"callouts",
154+
19, # Right after inline
155+
)
156+
157+
158+
makeExtension = CalloutsExtension
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
import re
4+
import xml.etree.ElementTree as etree
5+
6+
from markdown import Markdown, util
7+
from markdown.blockprocessors import BlockQuoteProcessor
8+
from markdown.extensions import Extension
9+
10+
11+
# Based on https://github.com/Python-Markdown/markdown/blob/4acb949256adc535d6e6cd84c4fb47db8dda2f46/markdown/blockprocessors.py#L277
12+
class _GitHubCalloutsBlockProcessor(BlockQuoteProcessor):
13+
REGEX = re.compile(
14+
r"((?:^|\n) *(?:[^>].*)?(?:^|\n)) {0,3}> *\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\] *\n(?: *> *\n)*() *(?:> *[^\s\n]|[^\s\n>])",
15+
flags=re.IGNORECASE,
16+
)
17+
18+
def test(self, parent, block):
19+
return (
20+
bool(self.REGEX.search(block))
21+
and not self.parser.state.isstate("blockquote")
22+
and not util.nearing_recursion_limit()
23+
)
24+
25+
def run(self, parent: etree.Element, blocks: list[str]) -> None:
26+
block = blocks.pop(0)
27+
m = self.REGEX.search(block)
28+
assert m
29+
30+
before = block[: m.end(1)]
31+
block = "\n".join(self.clean(line) for line in block[m.end(3) :].split("\n"))
32+
self.parser.parseBlocks(parent, [before])
33+
kind = m[2]
34+
35+
css_class = kind.lower()
36+
if css_class == "caution":
37+
css_class = "danger"
38+
admon = etree.SubElement(parent, "div", {"class": "admonition " + css_class})
39+
title = etree.SubElement(admon, "p", {"class": "admonition-title"})
40+
title.text = kind.title()
41+
42+
self.parser.state.set("blockquote")
43+
self.parser.parseChunk(admon, block)
44+
self.parser.state.reset()
45+
46+
47+
class GitHubCalloutsExtension(Extension):
48+
def extendMarkdown(self, md: Markdown) -> None:
49+
parser = md.parser # type: ignore
50+
parser.blockprocessors.register(
51+
_GitHubCalloutsBlockProcessor(parser),
52+
"github-callouts",
53+
21.1, # Right before blockquote
54+
)
55+
56+
57+
makeExtension = GitHubCalloutsExtension

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ Issues = "https://github.com/oprypin/markdown-callouts/issues"
4242
History = "https://github.com/oprypin/markdown-callouts/releases"
4343

4444
[project.entry-points."markdown.extensions"]
45-
callouts = "markdown_callouts:CalloutsExtension"
45+
callouts = "markdown_callouts.callouts:CalloutsExtension"
46+
github-callouts = "markdown_callouts.github_callouts:GitHubCalloutsExtension"
4647

4748
[tool.hatch.version]
4849
path = "markdown_callouts/__init__.py"

0 commit comments

Comments
 (0)