diff --git a/general/g.region/g.region.md b/general/g.region/g.region.md index 80e249854f7..604dd3ad516 100644 --- a/general/g.region/g.region.md +++ b/general/g.region/g.region.md @@ -101,7 +101,7 @@ the number of columns. The **-p** (or **-g**) option is recognized last. This means that all changes are applied to the region settings before printing occurs. -The ****format=shell** parameter prints the current region settings in shell +The **format=shell** parameter prints the current region settings in shell script style. This format can be given back to *g.region* on its command line. This may also be used to save region settings as shell environment variables with the UNIX eval command, "`` eval `g.region -p format=shell` ``". diff --git a/include/Make/Grass.make b/include/Make/Grass.make index 1864e90e832..63c9895a3b9 100644 --- a/include/Make/Grass.make +++ b/include/Make/Grass.make @@ -98,7 +98,7 @@ YFLAGS = -d -v MANSECT = 1 MANBASEDIR = $(ARCH_DISTDIR)/docs/man MANDIR = $(MANBASEDIR)/man$(MANSECT) -HTML2MAN = VERSION_NUMBER=$(GRASS_VERSION_NUMBER) $(GISBASE)/utils/g.html2man.py +MD2MAN = VERSION_NUMBER=$(GRASS_VERSION_NUMBER) $(GISBASE)/utils/g.md2man.py DEPFILE = depend.mk diff --git a/include/Make/Html.make b/include/Make/Html.make index 42d5284ecd9..7d7e843ab1b 100644 --- a/include/Make/Html.make +++ b/include/Make/Html.make @@ -11,8 +11,8 @@ $(MDDIR)/source/%.md: %.md %.tmp.md $(HTMLSRC) $(IMGDST_MD) | $(MDDIR) VERSION_NUMBER=$(GRASS_VERSION_NUMBER) VERSION_DATE=$(GRASS_VERSION_DATE) MODULE_TOPDIR=$(MODULE_TOPDIR) \ $(PYTHON) $(GISBASE)/utils/mkmarkdown.py $* > $@ -$(MANDIR)/%.$(MANSECT): $(HTMLDIR)/%.html - $(HTML2MAN) "$<" "$@" +$(MANDIR)/%.$(MANSECT): $(MDDIR)/source/%.md + $(MD2MAN) "$<" "$@" %.tmp.html: $(HTMLSRC) if [ "$(HTMLSRC)" != "" ] ; then $(call htmldesc,$<,$@) ; fi diff --git a/man/Makefile b/man/Makefile index 820b8a9f830..36856be6117 100644 --- a/man/Makefile +++ b/man/Makefile @@ -2,7 +2,20 @@ MODULE_TOPDIR = .. include $(MODULE_TOPDIR)/include/Make/Other.make -MANPAGES := $(patsubst $(HTMLDIR)/%.html,$(MANDIR)/%.$(MANSECT),$(wildcard $(HTMLDIR)/*.html)) +# Some source/*.md files exist only for the MkDocs website and have no man +# page equivalent. index.md is the site landing page, keywords.md is a stub +# for the tags plugin (the keyword index is rendered by MkDocs), and the +# *_intro.md pages are navigation-only introductions. The remaining ones are +# the developer and user guides installed from doc/ and doc/development/; +# they are derived from those directories so a newly added guide is excluded +# automatically. grass_database and projectionintro are the exception: they +# are reference pages that also ship as man pages. +DOC_MD := $(wildcard $(MODULE_TOPDIR)/doc/*.md) \ + $(wildcard $(MODULE_TOPDIR)/doc/development/*.md) +WEB_ONLY_MD := index keywords development_intro command_line_intro \ + $(filter-out grass_database projectionintro,$(basename $(notdir $(DOC_MD)))) +MANPAGES := $(filter-out $(patsubst %,$(MANDIR)/%.$(MANSECT),$(WEB_ONLY_MD)),\ + $(patsubst $(MDDIR)/source/%.md,$(MANDIR)/%.$(MANSECT),$(wildcard $(MDDIR)/source/*.md))) DSTFILES := \ $(HTMLDIR)/grassdocs.css \ @@ -66,8 +79,8 @@ default: $(DSTFILES) @echo "Generating manual pages index (help system)..." $(MAKE) $(INDICES) $(call build,check) - $(MAKE) manpages $(MAKE) $(INDICES_MD) + $(MAKE) manpages # $(MAKE) build-mkdocs # This must be a separate target so that evaluation of $(MANPAGES) diff --git a/utils/Makefile b/utils/Makefile index a8396947a0e..681f3b84f41 100644 --- a/utils/Makefile +++ b/utils/Makefile @@ -1,6 +1,6 @@ MODULE_TOPDIR = .. -SUBDIRS = timer g.html2man +SUBDIRS = timer g.html2man g.md2man include $(MODULE_TOPDIR)/include/Make/Dir.make include $(MODULE_TOPDIR)/include/Make/Compile.make diff --git a/utils/g.md2man/Makefile b/utils/g.md2man/Makefile new file mode 100644 index 00000000000..e38420e50b1 --- /dev/null +++ b/utils/g.md2man/Makefile @@ -0,0 +1,13 @@ +MODULE_TOPDIR = ../.. + +include $(MODULE_TOPDIR)/include/Make/Other.make + +TARGETS := $(patsubst %.py,$(UTILSDIR)/%.py,gmd.py g.md2man.py) + +default: $(TARGETS) + +$(UTILSDIR)/g.md2man.py: g.md2man.py + $(INSTALL) $< $@ + +$(UTILSDIR)/%.py: %.py + $(INSTALL_DATA) $< $@ diff --git a/utils/g.md2man/g.md2man.py b/utils/g.md2man/g.md2man.py new file mode 100755 index 00000000000..7723bb76ffb --- /dev/null +++ b/utils/g.md2man/g.md2man.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 by the GRASS Development Team +# SPDX-License-Identifier: GPL-2.0-or-later +"""Convert generated GRASS Markdown documentation to a man page. + +Usage: VERSION_NUMBER=x.y.z g.md2man.py input.md output.1 + +The input is a file produced by mkmarkdown.py (or any of the generated +index pages). The MkDocs content tabs are reduced to the command line +variant: man pages document the command line interface, and the Python +API tabs only repeat the same parameters in Python syntax. +""" + +import os +import re +import sys +from io import StringIO +from pathlib import Path + +try: + # When installed, ggroff.py sits next to this script in $GISBASE/utils. + from ggroff import Formatter +except ImportError: + # In the source tree it lives in the sibling g.html2man directory. + sys.path.append(str(Path(__file__).resolve().parent.parent / "g.html2man")) + from ggroff import Formatter + +import gmd + +CLI_TAB_TITLE = "Command line" +# The Python API tabs are matched by prefix so that both the current +# "Python (grass.tools)" and "Python (grass.script)" tabs, and any future +# variant, are recognized. These titles come from lib/gis/parser_md.c. +PYTHON_TAB_PREFIX = "Python (" + + +def transform_tabs(nodes): + """Replace tabs nodes according to the man page tab policy. + + A tab group generated by --md-description (one with a command line + tab) is replaced by the content of the command line tab. In any other + group (e.g., hand-written examples) the Python API tabs are dropped + and each remaining tab becomes a subsection titled after the tab. + """ + out = [] + for node in nodes: + if isinstance(node, tuple): + tag, attrs, body = node + if tag == "tabs": + out.extend(expand_tabs(body)) + continue + if isinstance(body, list): + node = (tag, attrs, transform_tabs(body)) + out.append(node) + return out + + +def expand_tabs(tabs): + has_cli = any(dict(attrs).get("title") == CLI_TAB_TITLE for _, attrs, _ in tabs) + out = [] + for _tag, attrs, body in tabs: + title = dict(attrs).get("title") + if title and title.startswith(PYTHON_TAB_PREFIX): + continue + if has_cli and title != CLI_TAB_TITLE: + # A generated group (one with a command line tab) should only + # contain the command line and Python tabs; anything else means + # the producer's tab titles drifted from what is expected here. + sys.stderr.write(f"warning: unexpected tab in generated group: {title}\n") + if not has_cli: + out.append(("h4", [], [title])) + out.extend(transform_tabs(body)) + return out + + +def plain_text(content): + """Extract the text of a tree fragment, ignoring markup.""" + if isinstance(content, str): + return content + if isinstance(content, tuple): + return plain_text(content[2]) + if isinstance(content, list): + return "".join(plain_text(item) for item in content) + return "" + + +def normalized_text(content): + return " ".join(plain_text(content).split()) + + +def is_tag(node, *tags): + return isinstance(node, tuple) and node[0] in tags + + +def build_document(meta, nodes, fallback_name): + """Assemble the final tree with NAME/KEYWORDS/SYNOPSIS sections.""" + nodes = transform_tabs(nodes) + heading = nodes.pop(0) if nodes and is_tag(nodes[0], "h1") else None + name = meta.get("name", fallback_name) + description = meta.get("description") + if not ("name" in meta and description): + # Index and overview pages: keep the page heading as a section. + if heading: + nodes.insert(0, ("h2", heading[1], heading[2])) + return name, nodes + + # NAME and KEYWORDS content is emitted as plain text rather than + # paragraphs to avoid .PP macros directly under the section headings. + out = [ + ("h2", [], ["NAME"]), + "\n", + ("b", [], [name]), + " - " + description, + ] + # The paragraphs between the heading and the synopsis repeat the + # description from the front matter; drop them to match the layout + # of a man page NAME section. + while ( + nodes + and is_tag(nodes[0], "p") + and normalized_text(nodes[0]) in normalized_text(description) + ): + nodes.pop(0) + if meta.get("keywords"): + out.extend([("h2", [], ["KEYWORDS"]), "\n" + ", ".join(meta["keywords"])]) + # The generated usage (the former command line tab) has no heading + # of its own; in a man page it is the SYNOPSIS. The bare name and + # --help lines are man page conventions kept from the previous + # HTML-based pages. + if nodes and not is_tag(nodes[0], "h2", "h3", "h4"): + out.extend( + [ + ("h2", [], ["SYNOPSIS"]), + "\n", + ("b", [], [name]), + ("br", [], None), + ("b", [], [name + " --help"]), + ("br", [], None), + ] + ) + return name, out + nodes + + +def main(): + infile = Path(sys.argv[1]) + meta, nodes = gmd.parse(infile.read_text(encoding="UTF-8")) + name, tree = build_document(meta, nodes, infile.name.removesuffix(".md")) + + stream = StringIO() + Formatter(str(infile), stream).pp(tree) + text = stream.getvalue() + + # Strip excess whitespace (same cleanup as g.html2man). + text = re.sub(r"[ \t\n]*\n([ \t]*\n)*", "\n", text).lstrip() + + version = os.environ.get("VERSION_NUMBER", "") + title = f'.TH {name} 1 "" "GRASS {version}" "GRASS User\'s Manual"\n' + Path(sys.argv[2]).write_bytes((title + text).encode("UTF-8")) + + +if __name__ == "__main__": + main() diff --git a/utils/g.md2man/gmd.py b/utils/g.md2man/gmd.py new file mode 100644 index 00000000000..b89c0eaa13b --- /dev/null +++ b/utils/g.md2man/gmd.py @@ -0,0 +1,625 @@ +# Copyright (C) 2026 by the GRASS Development Team +# SPDX-License-Identifier: GPL-2.0-or-later +"""Parse the Markdown subset used by GRASS documentation into a tag tree. + +The tree consists of (tag, attrs, body) tuples using HTML tag names, the +same structure that ghtml.HTMLParser produces, so it can be formatted to +groff by ggroff.Formatter (shared with g.html2man). + +Beyond standard Markdown, the parser handles the MkDocs constructs that +appear in the generated documentation: YAML front matter, content tabs +(=== "Title"), admonitions (!!! type "Title"), and the  -indented +parameter lists produced by --md-description. Tabs are returned as +("tabs", [], [("tab", [("title", ...)], body), ...]) nodes so the caller +can decide which tabs to keep. +""" + +import html +import re +import sys +from pathlib import Path + +__all__ = ["parse", "parse_blocks", "parse_inline"] + +FENCE_RE = re.compile(r"^(```+|~~~+)\s*(\S*)\s*$") +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$") +TAB_RE = re.compile(r'^===\s+"(.*)"\s*$') +ADMONITION_RE = re.compile(r'^!!!\s+(\S+)(?:\s+"(.*)")?\s*$') +HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})\s*$") +LIST_ITEM_RE = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$") +TABLE_ROW_RE = re.compile(r"^\s*\|") +TABLE_SEP_RE = re.compile(r"^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$") +BLOCKQUOTE_RE = re.compile(r"^\s*>\s?") +NBSP_RUN_RE = re.compile(r"^(?: )+") +FRONT_MATTER_KEY_RE = re.compile(r"^([A-Za-z_][\w-]*):\s*(.*)$") + +# Only these names trigger HTML parsing, so that non-HTML angle-bracket +# text (e.g. placeholders) is left alone. The lookahead +# excludes matches like (a tool name, not an tag). +HTML_TAG_RE = re.compile( + r"])", + re.IGNORECASE, +) + +# The emphasis content groups are fully lazy ((?:...)?? rather than +# (?:...)?) so that a span closes at the earliest closing marker. The em +# pattern allows internal **strong** spans so that emphasis like +# *text with **bold** inside* nests correctly. +INLINE_RE = re.compile( + r"(?P`+)" + r"|(?P\[!\[(?P[^\]]*)\]\([^)]*\)\]\((?P[^)]*)\))" + r"|(?P!\[(?P[^\]]*)\]\((?P[^)]*)\))" + r"|(?P\[(?P[^\]]+)\]\((?P[^)]*)\))" + r"|(?P<(?Phttps?://[^>\s]+)>)" + r"|\*\*\*(?P[^*\s](?:.*?[^*\s])??)\*\*\*" + r"|\*\*(?P[^*\s](?:.*?[^*\s])??)\*\*" + r"|\*(?P[^*\s](?:(?:[^*]|\*\*[^*]+?\*\*)*?[^*\s])??)\*(?!\*)", + re.DOTALL, +) + +# Backslash escapes are replaced with private use area placeholders +# before inline parsing so that an emphasis span cannot pair with an +# escaped asterisk; they are restored when text is emitted. +ESCAPE_RE = re.compile(r"\\([\\`*_{}\[\]()#+\-.!<>|])") +ESCAPE_PLACEHOLDER_BASE = 0xE000 +ESCAPE_PLACEHOLDER_RE = re.compile(r"[\ue000-\ue0ff]") + + +def protect_escapes(text): + return ESCAPE_RE.sub(lambda m: chr(ESCAPE_PLACEHOLDER_BASE + ord(m.group(1))), text) + + +def restore_escapes(text, keep_backslash=False): + def restore(match): + char = chr(ord(match.group(0)) - ESCAPE_PLACEHOLDER_BASE) + return "\\" + char if keep_backslash else char + + return ESCAPE_PLACEHOLDER_RE.sub(restore, text) + + +# Entities that html.unescape maps to characters groff input should not +# contain; ggroff escapes the rest itself. +CHARACTER_REPLACEMENTS = {"\xa0": " ", "•": "*"} + +GHTML_ENTITIES = {"nbsp": " ", "bull": "*"} + + +def _ghtml_parser(): + """Create an HTML parser, importing ghtml from g.html2man if needed.""" + try: + from ghtml import HTMLParser + except ImportError: + sys.path.append(str(Path(__file__).resolve().parent.parent / "g.html2man")) + from ghtml import HTMLParser + return HTMLParser(GHTML_ENTITIES) + + +def unescape_text(text): + """Decode HTML entities and escape placeholders in plain text.""" + text = html.unescape(text) + for char, replacement in CHARACTER_REPLACEMENTS.items(): + text = text.replace(char, replacement) + return restore_escapes(text) + + +def parse_inline(text): + """Parse inline Markdown into a list of tree nodes and strings.""" + # Protecting escapes again on recursive calls is a no-op. + text = protect_escapes(text) + nodes = [] + + def emit_text(segment): + if segment: + nodes.append(unescape_text(segment)) + + pos = 0 + while pos < len(text): + match = INLINE_RE.search(text, pos) + if not match: + emit_text(text[pos:]) + break + emit_text(text[pos : match.start()]) + pos = match.end() + if match.group("code"): + fence = match.group("code") + end = text.find(fence, pos) + if end < 0: + emit_text(fence) + else: + # Code spans are literal; escaped characters keep their + # backslash. + content = restore_escapes(text[pos:end].strip(), keep_backslash=True) + nodes.append(("code", [], [content])) + pos = end + len(fence) + elif match.group("img"): + # Images cannot be rendered in a man page; g.html2man drops + # them as well. + pass + elif match.group("limg"): + # For an image wrapped in a link, keep the alt text. + nodes.extend(parse_inline(match.group("limgalt"))) + elif match.group("link"): + # Man pages cannot follow links; keep the link text only, + # like g.html2man does. + nodes.extend(parse_inline(match.group("linktext"))) + elif match.group("auto"): + nodes.append(unescape_text(match.group("autourl"))) + elif match.group("strongem"): + nodes.append(("b", [], [("i", [], parse_inline(match.group("strongem")))])) + elif match.group("strong"): + nodes.append(("b", [], parse_inline(match.group("strong")))) + elif match.group("em"): + nodes.append(("i", [], parse_inline(match.group("em")))) + return nodes + + +def unquote_yaml(value): + """Decode a YAML scalar as written by parser_md.c's print_yaml_escaped. + + A double-quoted value has its surrounding quotes removed and the only + escapes the producer emits, \\" and \\\\, undone. Unquoted values are + returned unchanged. + """ + if len(value) >= 2 and value[0] == '"' and value[-1] == '"': + value = value[1:-1] + value = re.sub(r"\\(.)", r"\1", value) + return value + + +def parse_front_matter(lines): + """Parse YAML front matter, returning (metadata, remaining lines). + + Only the flat key: value and key: [list] forms used by the generated + documentation are recognized; nested keys are ignored. + """ + meta = {} + if not lines or lines[0].strip() != "---": + return meta, lines + i = 1 + while i < len(lines) and lines[i].strip() != "---": + match = FRONT_MATTER_KEY_RE.match(lines[i]) + if match: + key, value = match.group(1), match.group(2).strip() + if value.startswith("[") and value.endswith("]"): + meta[key] = [ + item.strip().strip("\"'") + for item in value[1:-1].split(",") + if item.strip() + ] + elif value: + meta[key] = unquote_yaml(value) + i += 1 + if i >= len(lines): + # Unterminated block: treat the file as having no front matter. + return {}, lines + return meta, lines[i + 1 :] + + +def strip_line_comments(line): + """Remove complete HTML comments from a single line. + + Returns the cleaned line and whether a comment is left open (an + unterminated ```` is on a later line). + """ + while True: + start = line.find("", start + 4) + if end < 0: + return line[:start], True + line = line[:start] + line[end + 3 :] + + +def strip_html_comments(lines): + """Remove HTML comments, except inside fenced code blocks. + + Comments are handled line by line (rather than with a single regular + expression) so that fenced code blocks are preserved and comments + spanning multiple lines are removed correctly. + """ + out = [] + in_fence = False + in_comment = False + for line in lines: + if not in_comment and FENCE_RE.match(line.strip()): + in_fence = not in_fence + if in_fence: + out.append(line) + continue + if in_comment: + end = line.find("-->") + if end < 0: + continue + line = line[end + 3 :] + in_comment = False + line, in_comment = strip_line_comments(line) + out.append(line) + return out + + +# The opener is anchored to the start of the line so that a \n\nVisible text.\n" + result = convert(md, tmp_path) + assert "color" not in result + assert "Visible text." in result + + +def test_front_matter_parsing(): + meta, nodes = gmd.parse(TOOL_PAGE) + assert meta["name"] == "r.example" + assert meta["keywords"] == ["raster", "example"] + assert meta["description"].startswith("Does example things.") + + +def test_front_matter_unescapes_quotes(tmp_path): + # parser_md.c wraps the description in double quotes and escapes any + # embedded " and \ as \" and \\; the parser must undo that so the NAME + # line is clean and matches the (unescaped) body paragraph for dedup. + md = ( + "---\nname: r.volume\n" + 'description: "Calculates the volume of data \\"clumps\\"."\n' + "keywords: [ raster ]\n---\n\n# r.volume\n\n" + 'Calculates the volume of data "clumps".\n\n' + "## DESCRIPTION\n\nText.\n" + ) + meta, _nodes = gmd.parse(md) + assert meta["description"] == 'Calculates the volume of data "clumps".' + result = convert(md, tmp_path, filename="r.volume.md") + assert "\\fBr.volume\\fR \\- Calculates the volume of data" in result + # The escaped backslash sequences must not survive into the output, and + # the description must not be duplicated below SYNOPSIS. + assert "\\\\" not in result + assert result.count("clumps") == 1 + + +def test_front_matter_nested_keys_ignored(): + meta, nodes = gmd.parse("---\nhide:\n - toc\n---\n\n# Title\n") + assert nodes[0][0] == "h1" + + +def test_inline_emphasis(): + nodes = gmd.parse_inline("**bold** and *italic* and `code`") + assert ("b", [], ["bold"]) in nodes + assert ("i", [], ["italic"]) in nodes + assert ("code", [], ["code"]) in nodes + + +def test_inline_escapes_and_autolink(): + # The escaped asterisk stays literal and the autolink becomes a plain + # text node holding the URL. + nodes = gmd.parse_inline(r"a \* literal ") + assert nodes == ["a * literal ", "https://example.com"] + + +def test_escaped_asterisk_does_not_pair_with_emphasis(): + nodes = gmd.parse_inline(r"The *db.\** set and *v.db.\** set") + assert ("i", [], ["db.*"]) in nodes + assert ("i", [], ["v.db.*"]) in nodes + + +def test_emphasis_closes_at_earliest_marker(): + nodes = gmd.parse_inline(r"\[-**s**\] \[-**t**\]") + assert nodes == ["[-", ("b", [], ["s"]), "] [-", ("b", [], ["t"]), "]"] + + +def test_linked_image_keeps_alt_text(): + nodes = gmd.parse_inline("[![Raster](gi_raster.jpg)](raster_graphical.md)") + assert nodes == ["Raster"] + + +def test_code_span_keeps_backslash_escapes(): + nodes = gmd.parse_inline(r"`a\*b`") + assert nodes == [("code", [], ["a\\*b"])] + + +def test_tool_placeholder_not_html(): + # Angle-bracket placeholders like are not HTML tags. + (node,) = gmd.parse_blocks(["Use here."]) + assert node[0] == "p" + assert "Use here." in node[2] + + +def test_blockquote_content_kept(tmp_path): + md = "Intro text.\n\n> xps \\>= 0 means the target is at sea level.\n> More quoted text.\n" + result = convert(md, tmp_path) + assert ">= 0 means the target" in result + assert "More quoted text." in result + assert "\n> " not in result + + +def test_stray_closing_tag_does_not_crash(tmp_path): + md = "Some text.\n\n \n\nMore text.\n" + result = convert(md, tmp_path) + assert "Some text." in result + assert "More text." in result + assert "li" not in result + + +def test_html_only_page(tmp_path): + # g.extension copies .html to .md for addons that have + # no Markdown documentation; the converter must handle HTML headings. + md = "

DESCRIPTION

\n\nr.fake uses input.\n\n
\nr.fake input=elev\n
\n" + result = convert(md, tmp_path) + assert ".SH DESCRIPTION" in result + assert "

" not in result + assert "\\fBinput\\fR" in result + assert "r.fake input=elev" in result + + +def test_html_unclosed_paragraphs_kept(tmp_path): + # Legacy HTML omits the closing

; the following

must not empty + # the parser's tag stack and silently drop the first paragraph. + md = "

DESCRIPTION

\n\n

First paragraph.\n\n

Second paragraph.\n" + result = convert(md, tmp_path) + assert "First paragraph." in result + assert "Second paragraph." in result + + +def test_inline_comment_removed(tmp_path): + result = convert("Before after.\n", tmp_path) + assert "Before after." in result + assert "hide this" not in result + + +def test_multiline_comment_removed(tmp_path): + md = "Visible start.\n\n\n\nVisible end.\n" + result = convert(md, tmp_path) + assert "Visible start." in result + assert "Visible end." in result + assert "comment" not in result + assert "spanning" not in result + + +def test_comment_kept_in_fenced_code(tmp_path): + # A comment inside a code block is documentation content, not markup, + # so it must survive (groff escapes the dashes in the output). + md = "```html\n\n```\n" + result = convert(md, tmp_path) + assert "required" in result + + +def test_comment_parsing_unit(): + lines = ["a b c", "d"] + assert gmd.strip_html_comments(lines) == ["a b ", " c", "d"]