diff --git a/docs/api/ford.md_admonition.rst b/docs/api/ford.md_admonition.rst
new file mode 100644
index 00000000..58acbfe0
--- /dev/null
+++ b/docs/api/ford.md_admonition.rst
@@ -0,0 +1,7 @@
+ford.md\_admonition module
+==========================
+
+.. automodule:: ford.md_admonition
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/api/ford.rst b/docs/api/ford.rst
index aa971684..9300eba3 100644
--- a/docs/api/ford.rst
+++ b/docs/api/ford.rst
@@ -16,6 +16,7 @@ Submodules
ford.fortran_project
ford.graphs
ford.intrinsics
+ ford.md_admonition
ford.md_environ
ford.md_striped_table
ford.output
diff --git a/docs/user_guide/note_box.png b/docs/user_guide/note_box.png
new file mode 100644
index 00000000..589f5299
Binary files /dev/null and b/docs/user_guide/note_box.png differ
diff --git a/docs/user_guide/writing_documentation.rst b/docs/user_guide/writing_documentation.rst
index 5f6f60d4..7a695967 100644
--- a/docs/user_guide/writing_documentation.rst
+++ b/docs/user_guide/writing_documentation.rst
@@ -140,25 +140,74 @@ back to number equations as you would in a LaTeX document. For more
details on that feature, see the `MathJax Documentation
`__.
-Special Environments
---------------------
-
-Much like in Doxygen, you can use a ``@note`` environment to place the
-succeeding documentation into a special boxed paragraph. This syntax may
-be used at any location in the documentation comment and it will include
-as the note’s contents anything until the first use of ``@endnote``
-(provided there are no new ``@note`` or other environments, described
-below, started before then). If no such ``@endnote`` tag can be found
-then the note’s contents will include until the end of the paragraph
-where the environment was activated. Other environments which behave the
-same way are ``@warning``, ``@todo``, and ``@bug``.
-
-Note that these designations are case-insensitive (which, as Fortran
-programmers, we’re all used to). If these environments are used within
-the first paragraph of something’s documentation and you do not manually
-specify a summary, then the environment will be included in the summary
-of your documentation. If you do not want it included, just place the
-environment in a new paragraph of its own.
+.. _sec-note-boxes:
+
+Notes and Warning Boxes
+-----------------------
+
+If you want to call particular attention to a piece of information,
+you can use the ``@note`` markup to place it in a highlighted box:
+
+.. code:: markdown
+
+ @note
+ You can include any notes (or bugs, warnings, or todos) like so.
+ @endnote
+
+becomes:
+
+.. figure:: note_box.png
+ :alt: An example of a @note box
+
+ An example of a ``@note`` box
+
+This syntax may be used at almost any location in the documentation
+comment and it will include as the note’s contents anything until the
+first use of ``@endnote`` (provided there are no new ``@note`` or
+other boxes, described below, started before then). If no such
+``@endnote`` tag can be found then the note’s contents will include
+until the end of the paragraph where the environment was activated.
+
+There are some variations on ``@note`` boxes, which are coloured
+differently:
+
+- ``@note``
+- ``@warning``
+- ``@todo``
+- ``@bug``
+- ``@history``
+
+You can give them a custom title by putting it in quotes immediately
+after the tag:
+
+.. code:: markdown
+
+ @note "Custom title"
+ Note text
+ @endnote
+
+These boxes all use the CSS class ``alert``, as well as
+``alert-`` (for example, ``alert-note``), so you can customise
+them if you wish. You can even add your own CSS classes, although you
+must also give a title in that case:
+
+.. code:: markdown
+
+ @note highlight blink "Title"
+ Note text
+ @endnote
+
+Note that these tags are case-insensitive (which, as Fortran
+programmers, we’re all used to). If a note is used within the first
+paragraph of something’s documentation and you do not manually specify
+a summary, then the note will be included in the summary of your
+documentation. If you do not want it included, just place the note in
+a new paragraph of its own.
+
+Notes can include other markdown, such as lists or code blocks, and
+can be used in other places such as lists -- although you need to be
+careful about indentation in such cases.
+
“Include” Capabilities
----------------------
diff --git a/ford/__init__.py b/ford/__init__.py
index e6f8ac38..43bd296b 100755
--- a/ford/__init__.py
+++ b/ford/__init__.py
@@ -578,19 +578,15 @@ def main(proj_data, proj_docs, md):
if proj_data["summary"] is not None:
proj_data["summary"] = md.convert(proj_data["summary"])
proj_data["summary"] = ford.utils.sub_links(
- ford.utils.sub_macros(ford.utils.sub_notes(proj_data["summary"])), project
+ ford.utils.sub_macros(proj_data["summary"]), project
)
if proj_data["author_description"] is not None:
proj_data["author_description"] = md.convert(proj_data["author_description"])
proj_data["author_description"] = ford.utils.sub_links(
- ford.utils.sub_macros(
- ford.utils.sub_notes(proj_data["author_description"])
- ),
+ ford.utils.sub_macros(proj_data["author_description"]),
project,
)
- proj_docs_ = ford.utils.sub_links(
- ford.utils.sub_macros(ford.utils.sub_notes(proj_docs)), project
- )
+ proj_docs_ = ford.utils.sub_links(ford.utils.sub_macros(proj_docs), project)
# Process any pages
if proj_data["page_dir"] is not None:
page_tree = get_page_tree(
diff --git a/ford/_markdown.py b/ford/_markdown.py
index 237ebd8c..45439513 100644
--- a/ford/_markdown.py
+++ b/ford/_markdown.py
@@ -2,6 +2,7 @@
from typing import Any, Dict, List, Union, Optional
from ford.md_environ import EnvironExtension
+from ford.md_admonition import AdmonitionExtension
class MetaMarkdown(Markdown):
@@ -20,6 +21,7 @@ def __init__(
"markdown.extensions.extra",
"mdx_math",
EnvironExtension(),
+ AdmonitionExtension(),
]
if extensions is None:
extensions = []
diff --git a/ford/css/local.css b/ford/css/local.css
index 29528e61..6aafd6d4 100644
--- a/ford/css/local.css
+++ b/ford/css/local.css
@@ -126,7 +126,12 @@ body {
margin-right: 5px;
margin-top: 5px;
}
-
+
+ .alert-title {
+ margin-top: 0;
+ color: inherit;
+ }
+
div.toc {
font-size: 14.73px;
padding-left: 0px;
diff --git a/ford/md_admonition.py b/ford/md_admonition.py
new file mode 100644
index 00000000..81555c5f
--- /dev/null
+++ b/ford/md_admonition.py
@@ -0,0 +1,251 @@
+"""
+Admonition Preprocessor
+=======================
+
+Markdown preprocessor for dealing with FORD style admonitions. See
+:ref:`sec-note-boxes` for details on using these in Ford docs.
+
+A preprocessor, :py:class:`AdmonitionPreprocessor`, converts Ford style
+``@note`` into something that the existing `markdown admonition
+extension`_ can handle. This is mostly a matter of making sure the
+note body is indented correctly. The conversion to HTML is done with a
+customised processor, :py:class:`FordAdmonitionProcessor`
+
+.. _markdown admonition extension:
+ https://python-markdown.github.io/extensions/admonition/
+
+"""
+
+import re
+from dataclasses import dataclass
+from typing import ClassVar, List
+from textwrap import indent
+
+from markdown.extensions import Extension
+from markdown.preprocessors import Preprocessor
+from markdown.extensions.admonition import AdmonitionProcessor
+
+ADMONITION_TYPE = {
+ "note": "info",
+ "warning": "warning",
+ "todo": "success",
+ "bug": "danger",
+ "history": "history",
+}
+
+
+class AdmonitionExtension(Extension):
+ """Admonition extension for Python-Markdown."""
+
+ def extendMarkdown(self, md):
+ """Add Admonition to Markdown instance."""
+ md.registerExtension(self)
+ md.parser.blockprocessors.deregister("admonition", strict=False)
+ md.preprocessors.register(AdmonitionPreprocessor(md), "admonition-pre", 105)
+ md.parser.blockprocessors.register(
+ FordAdmonitionProcessor(md.parser), "admonition", 105
+ )
+
+
+class FordMarkdownError(RuntimeError):
+ """Format an error when processing markdown, giving some context"""
+
+ def __init__(
+ self,
+ message: str,
+ line_number: int,
+ lines: List[str],
+ start: int,
+ end: int,
+ context: int = 4,
+ ):
+ line_start = line_number - context
+ line_end = line_number + context
+ line_context = lines[line_start:line_end]
+ num_len = len(f"{line_end}")
+ text_with_line_numbers = [
+ f"{line_start + n + 1:{num_len}d}: {line}"
+ for n, line in enumerate(line_context)
+ ]
+ marker = f"{' ' * (num_len + 2 + start)}{'^' * (end - start)}"
+ text_with_line_numbers.insert(context + 1, marker)
+ text = indent("\n".join(text_with_line_numbers), " ")
+ super().__init__(f"{message}:\n\n{text}")
+
+
+class FordAdmonitionProcessor(AdmonitionProcessor):
+ """Customised version of the `Python markdown`_ extension.
+
+ Uses our CSS class names for each specific note type.
+
+ .. _Python markdown:
+ https://python-markdown.github.io/extensions/admonition/
+ """
+
+ CLASSNAME = "alert"
+ CLASSNAME_TITLE = "alert-title h4"
+ RE = re.compile(
+ r"""(?:^|\n)@note ?(?P[\w\-]+(?: +[\w\-]+)*)(?: +"(?P.*?)")? *(?:\n|$)"""
+ )
+
+ def get_class_and_title(self, match):
+ """Get the CSS class and title for this admonition
+
+ Title defaults to the note class, while the CSS class is looked up
+ in the list of note types (`ADMONITION_TYPE`)
+
+ """
+ css, title = super().get_class_and_title(match)
+ if len(css_bits := css.split()) > 1:
+ klass = css_bits[0]
+ more_css = " ".join(css_bits[1:])
+ else:
+ klass = css
+ more_css = ""
+ return f"alert-{ADMONITION_TYPE[klass]} {more_css}", title
+
+
+class AdmonitionPreprocessor(Preprocessor):
+ """Markdown preprocessor for dealing with FORD style admonitions.
+
+ This preprocessor converts the FORD syntax for admonitions to
+ the markdown admonition syntax.
+
+ A FORD admonition starts with ``@``, where ```` is one of:
+ ``note``, ``warning``, ``todo``, ``bug``, or ``history``.
+ An admonition ends at (in this order of preference):
+
+ 1. ``@end``, where ```` must match the start marker
+ 2. an empty line
+ 3. a new note (``@``)
+ 4. the end of the documentation lines
+
+ The admonitions are converted to the markdown syntax, i.e. ``@note Note``,
+ followed by an indented block. Possible end markers are removed, as well
+ as empty lines if they mark the end of an admonition.
+ """
+
+ INDENT_SIZE: ClassVar[int] = 4
+ INDENT: ClassVar[str] = " " * INDENT_SIZE
+ ADMONITION_RE: ClassVar[re.Pattern] = re.compile(
+ rf"""(?P\s*)
+ @(?P{"|".join(ADMONITION_TYPE.keys())})
+ (?P(?:\ +[\w\-]+)*\ +".*")?\s*
+ (?P.*)
+ """,
+ re.IGNORECASE | re.VERBOSE,
+ )
+ END_RE: ClassVar[re.Pattern] = re.compile(
+ rf"""\s*@end(?P{"|".join(ADMONITION_TYPE.keys())})
+ \s*(?P.*)?""",
+ re.IGNORECASE | re.VERBOSE,
+ )
+ admonitions: List["Admonition"] = []
+
+ @dataclass
+ class Admonition:
+ """A single admonition block in the text."""
+
+ #: Admonition type (note, bug, and so on)
+ type: str
+ #: Line index of start marker
+ start_idx: int
+ #: Line index where admonition ends
+ end_idx: int = -1
+
+ def run(self, lines: List[str]) -> List[str]:
+ admonitions = self._find_admonitions(lines)
+ return self._process_admonitions(admonitions, lines)
+
+ def _find_admonitions(self, lines: List[str]) -> List[Admonition]:
+ """Scans the lines to search for admonitions."""
+ admonitions = []
+ current_admonition = None
+
+ for idx, line in enumerate(lines):
+ if match := self.ADMONITION_RE.search(line):
+ if current_admonition:
+ if current_admonition.end_idx == -1:
+ current_admonition.end_idx = idx
+ admonitions.append(current_admonition)
+ current_admonition = self.Admonition(type=match["type"], start_idx=idx)
+
+ if end := self.END_RE.search(line):
+ if not current_admonition:
+ raise FordMarkdownError(
+ "Note end marker found without start marker",
+ idx,
+ lines,
+ end.start(),
+ end.end(),
+ )
+
+ if end["type"].lower() != current_admonition.type.lower():
+ raise FordMarkdownError(
+ "Type of start and end marker don't match",
+ idx,
+ lines,
+ end.start(),
+ end.end(),
+ )
+
+ current_admonition.end_idx = idx
+ admonitions.append(current_admonition)
+ current_admonition = None
+
+ if current_admonition is None:
+ continue
+
+ if line == "" and current_admonition.end_idx == -1:
+ # empty line encountered while in an admonition. We set end_line but don't
+ # move it to the list yet since an end marker (@end...) may follow
+ # later.
+ current_admonition.end_idx = idx
+
+ if current_admonition:
+ # We reached the last line and the last admonition wasn't moved to the list yet.
+ if current_admonition.end_idx == -1:
+ current_admonition.end_idx = idx
+ admonitions.append(current_admonition)
+
+ return admonitions
+
+ def _process_admonitions(
+ self, admonitions: List[Admonition], lines: List[str]
+ ) -> List[str]:
+ """Processes the admonitions to convert the lines to the markdown syntax."""
+
+ # We handle the admonitions in the reverse order since
+ # we may be deleting lines.
+ for admonition in admonitions[::-1]:
+ # last line--deal with possible text before or after end marker
+ idx = admonition.end_idx
+ if end := self.END_RE.search(lines[idx]):
+ # Shove anything after the @end onto the next line
+ if end["posttxt"]:
+ lines.insert(idx + 1, end["posttxt"])
+
+ # Remove the @end and possibly the line too if it ends up blank
+ lines[idx] = self.END_RE.sub("", lines[idx])
+ if lines[idx].strip() == "":
+ del lines[idx]
+ else:
+ # New ending is now next line
+ admonition.end_idx += 1
+
+ # Indent any intermediate lines
+ for idx in range(admonition.start_idx + 1, admonition.end_idx):
+ if lines[idx] != "":
+ lines[idx] = self.INDENT + lines[idx]
+
+ idx = admonition.start_idx
+ if (match := self.ADMONITION_RE.search(lines[idx])) is None:
+ # Something has gone seriously wrong!
+ raise FordMarkdownError("Missing start of @note", idx, lines, 0, -1)
+
+ extra = match["extra"] or ""
+ lines[idx] = f"{match['indent']}@note {admonition.type.capitalize()}{extra}"
+ if posttxt := match["posttxt"]:
+ lines.insert(idx + 1, self.INDENT + match["indent"] + posttxt)
+
+ return lines
diff --git a/ford/output.py b/ford/output.py
index 8c7428ad..e22edb31 100644
--- a/ford/output.py
+++ b/ford/output.py
@@ -521,9 +521,7 @@ def render(self, data, proj, obj):
ford.pagetree.set_base_url(base_url)
data["project_url"] = base_url
template = env.get_template("info_page.html")
- obj.contents = ford.utils.sub_links(
- ford.utils.sub_macros(ford.utils.sub_notes(obj.contents)), proj
- )
+ obj.contents = ford.utils.sub_links(ford.utils.sub_macros(obj.contents), proj)
return template.render(data, page=obj, project=proj, topnode=obj.topnode)
def writeout(self):
diff --git a/ford/sourceform.py b/ford/sourceform.py
index a2c1d50f..6c39f14f 100644
--- a/ford/sourceform.py
+++ b/ford/sourceform.py
@@ -382,13 +382,11 @@ def markdown(self, md: MetaMarkdown, project: Project):
if hasattr(self, "num_lines"):
self.meta["num_lines"] = self.num_lines
- self.doc = ford.utils.sub_macros(ford.utils.sub_notes(self.doc))
+ self.doc = ford.utils.sub_macros(self.doc)
if self.meta.get("summary", None) is not None:
self.meta["summary"] = md.convert(self.meta["summary"])
- self.meta["summary"] = ford.utils.sub_macros(
- ford.utils.sub_notes(self.meta["summary"])
- )
+ self.meta["summary"] = ford.utils.sub_macros(self.meta["summary"])
elif paragraph := PARA_CAPTURE_RE.search(self.doc):
# If there is no stand-alone webpage for this item, e.g.
# an internal routine, make the whole doc blob appear,
diff --git a/ford/utils.py b/ford/utils.py
index 61807b43..b52cf0ba 100644
--- a/ford/utils.py
+++ b/ford/utils.py
@@ -50,26 +50,6 @@
if TYPE_CHECKING:
from ford.fortran_project import Project
-
-NOTE_TYPE = {
- "note": "info",
- "warning": "warning",
- "todo": "success",
- "bug": "danger",
- "history": "history",
-}
-NOTE_RE = [
- re.compile(
- r"@({})\s*(((?!@({})).)*?)@end\1\s*(
)?".format(
- note, "|".join(NOTE_TYPE.keys())
- ),
- re.IGNORECASE | re.DOTALL,
- )
- for note in NOTE_TYPE
-] + [
- re.compile(r"@({})\s*(.*?)\s*".format(note), re.IGNORECASE | re.DOTALL)
- for note in NOTE_TYPE
-]
LINK_RE = re.compile(r"\[\[(\w+(?:\.\w+)?)(?:\((\w+)\))?(?::(\w+)(?:\((\w+)\))?)?\]\]")
@@ -79,26 +59,6 @@
_MACRO_DICT: Dict[str, str] = {}
-def sub_notes(docs):
- """
- Substitutes the special controls for notes, warnings, todos, and bugs with
- the corresponding div.
- """
-
- def substitute(match):
- ret = (
- f''
- f"
{match.group(1).capitalize()}
{match.group(2)}
"
- )
- if len(match.groups()) >= 4 and not match.group(4):
- ret += "\n"
- return ret
-
- for regex in NOTE_RE:
- docs = regex.sub(substitute, docs)
- return docs
-
-
def get_parens(line: str, retlevel: int = 0, retblevel: int = 0) -> str:
"""
By default takes a string starting with an open parenthesis and returns the portion
diff --git a/test/test_md_admonition.py b/test/test_md_admonition.py
new file mode 100644
index 00000000..adbf9e36
--- /dev/null
+++ b/test/test_md_admonition.py
@@ -0,0 +1,283 @@
+import markdown
+from textwrap import dedent
+
+from ford.md_admonition import AdmonitionExtension, FordMarkdownError
+
+from bs4 import BeautifulSoup
+import pytest
+
+
+def convert(text: str) -> str:
+ md = markdown.Markdown(extensions=[AdmonitionExtension()], output_format="html")
+ return md.convert(dedent(text))
+
+
+def not_title(tag):
+ return tag.name == "p" and not tag.has_attr("class")
+
+
+def test_basic():
+ converted = convert(
+ """
+ @note note text
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "Note"
+ assert soup.div.find(not_title).text == "note text"
+
+
+def test_uppercase():
+ converted = convert(
+ """
+ @NOTE note text
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "Note"
+ assert soup.div.find(not_title).text == "note text"
+
+
+def test_endnote():
+ converted = convert(
+ """
+ @note note text @endnote
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "Note"
+ assert soup.div.find(not_title).text == "note text"
+
+
+def test_paragraph():
+ converted = convert(
+ """
+ @note note text
+ some following text
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "Note"
+ assert soup.div.find(not_title).text == "note text\nsome following text"
+
+
+def test_explicit_end():
+ converted = convert(
+ """
+ @note note text
+ some blank lines
+
+
+ before the end
+ @endnote
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "Note"
+ assert soup.div.text.strip() == "Note\nnote text\nsome blank lines\nbefore the end"
+
+
+def test_warning():
+ converted = convert(
+ """
+ @warning note text
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-warning"]
+ assert soup.find(class_="h4").text == "Warning"
+ assert soup.div.find(not_title).text == "note text"
+
+
+def test_in_list():
+ converted = convert(
+ """
+ - item 1
+
+ @note note text
+
+ - item 2
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "Note"
+ assert soup.div.find(not_title).text == "note text"
+
+
+def test_in_list_with_paragraph():
+ converted = convert(
+ """
+ - item 1
+
+ @note note text
+ with a paragraph
+
+ but not this one
+
+ - item 2
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "Note"
+ assert soup.div.find(not_title).text == "note text\nwith a paragraph"
+
+
+def test_in_list_with_paragraph_and_end():
+ converted = convert(
+ """
+ - item 1
+
+ @note note text
+ with a paragraph
+
+ and another one
+ @endnote
+
+ - item 2
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "Note"
+ all_text = "\n".join(p.text for p in soup.div.find_all(not_title))
+ assert all_text == "note text\nwith a paragraph\nand another one"
+
+
+def test_in_list_with_list():
+ converted = convert(
+ """
+ - item 1
+
+ @note note text
+
+ 1. first
+ 2. second
+
+ @endnote
+
+ - item 2
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "Note"
+ list_text = " ".join(li.text for li in soup.div.ol.find_all("li"))
+ assert list_text == "first second"
+
+
+# The following test doesn't currently work, but could be supported by
+# keeping track of a stack of admonitions. PRs welcome!
+
+# def test_in_list_with_nested_warning():
+# converted = convert(
+# """
+# - item 1
+
+# @note note text
+# with a paragraph
+
+# @warning nested warning
+
+# unnested paragraph
+# @endnote
+
+# - item 2
+# """
+# )
+
+# soup = BeautifulSoup(converted, features="html.parser")
+# assert len(soup) == 1
+# assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+# assert soup.find(class_="h4").text == "Note"
+
+# nested_box = soup.div.div.extract()
+# assert nested_box.find(class_="h4").text == "Warning"
+# assert nested_box.find(not_title).text == "nested warning"
+
+# all_text = "\n".join(p.text for p in soup.div.find_all(not_title))
+# assert all_text == "note text\nwith a paragraph\nunnested paragraph"
+
+
+def test_midparagraph():
+ converted = convert(
+ """
+ @Bug start text
+
+ end text @endbug post text
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 3
+ assert sorted(soup.div["class"]) == ["alert", "alert-danger"]
+ assert soup.find(class_="h4").text == "Bug"
+ all_text = "\n".join(p.text for p in soup.div.find_all(not_title))
+ assert all_text == "start text\nend text"
+
+ soup.div.extract()
+ assert soup.text.strip() == "post text"
+
+
+def test_title():
+ converted = convert(
+ """
+ @note "title" text
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == ["alert", "alert-info"]
+ assert soup.find(class_="h4").text == "title"
+ assert soup.div.find(not_title).text == "text"
+
+
+def test_css_and_title():
+ converted = convert(
+ """
+ @note some css "title" text
+ """
+ )
+
+ soup = BeautifulSoup(converted, features="html.parser")
+ assert len(soup) == 1
+ assert sorted(soup.div["class"]) == sorted(["alert", "alert-info", "some", "css"])
+ assert soup.find(class_="h4").text == "title"
+ assert soup.div.find(not_title).text == "text"
+
+
+def test_end_marker_without_start():
+ with pytest.raises(FordMarkdownError):
+ convert("@endnote")
+
+
+def test_end_marker_doesnt_match_start():
+ with pytest.raises(FordMarkdownError):
+ convert("@bug\n@endnote")