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 ``@<type>``, where ``<type>`` is one of: + ``note``, ``warning``, ``todo``, ``bug``, or ``history``. + An admonition ends at (in this order of preference): + + 1. ``@end<type>``, where ``<type>`` must match the start marker + 2. an empty line + 3. a new note (``@<type>``) + 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<indent>\s*) + @(?P<type>{"|".join(ADMONITION_TYPE.keys())}) + (?P<extra>(?:\ +[\w\-]+)*\ +".*")?\s* + (?P<posttxt>.*) + """, + re.IGNORECASE | re.VERBOSE, + ) + END_RE: ClassVar[re.Pattern] = re.compile( + rf"""\s*@end(?P<type>{"|".join(ADMONITION_TYPE.keys())}) + \s*(?P<posttxt>.*)?""", + 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*(</p>)?".format( - note, "|".join(NOTE_TYPE.keys()) - ), - re.IGNORECASE | re.DOTALL, - ) - for note in NOTE_TYPE -] + [ - re.compile(r"@({})\s*(.*?)\s*</p>".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'</p><div class="alert alert-{NOTE_TYPE[match.group(1).lower()]}" role="alert">' - f"<h4>{match.group(1).capitalize()}</h4><p>{match.group(2)}</p></div>" - ) - if len(match.groups()) >= 4 and not match.group(4): - ret += "\n<p>" - 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")