From babf666ae2f4c32da70f225bd17ba22ebd9b6d4c Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Fri, 12 Jun 2026 23:17:05 -0400 Subject: [PATCH 1/7] doc: generate man pages from markdown --- general/g.region/g.region.md | 2 +- include/Make/Grass.make | 1 + include/Make/Html.make | 4 +- man/Makefile | 7 +- utils/Makefile | 2 +- utils/g.md2man/Makefile | 13 + utils/g.md2man/g.md2man.py | 154 ++++++++ utils/g.md2man/gmd.py | 509 ++++++++++++++++++++++++++ utils/g.md2man/tests/g_md2man_test.py | 294 +++++++++++++++ 9 files changed, 980 insertions(+), 6 deletions(-) create mode 100644 utils/g.md2man/Makefile create mode 100644 utils/g.md2man/g.md2man.py create mode 100644 utils/g.md2man/gmd.py create mode 100644 utils/g.md2man/tests/g_md2man_test.py 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..e50cb95dbfc 100644 --- a/include/Make/Grass.make +++ b/include/Make/Grass.make @@ -99,6 +99,7 @@ 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..8bb2ee87c1e 100644 --- a/man/Makefile +++ b/man/Makefile @@ -2,7 +2,10 @@ MODULE_TOPDIR = .. include $(MODULE_TOPDIR)/include/Make/Other.make -MANPAGES := $(patsubst $(HTMLDIR)/%.html,$(MANDIR)/%.$(MANSECT),$(wildcard $(HTMLDIR)/*.html)) +# keywords.md is a static stub for the MkDocs tags plugin and has no +# content to convert to a man page. +MANPAGES := $(filter-out $(MANDIR)/keywords.$(MANSECT),\ + $(patsubst $(MDDIR)/source/%.md,$(MANDIR)/%.$(MANSECT),$(wildcard $(MDDIR)/source/*.md))) DSTFILES := \ $(HTMLDIR)/grassdocs.css \ @@ -66,8 +69,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 100644 index 00000000000..48ffca8e9b8 --- /dev/null +++ b/utils/g.md2man/g.md2man.py @@ -0,0 +1,154 @@ +#!/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" +PYTHON_TAB_TITLES = ("Python (grass.tools)", "Python (grass.script)") + + +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 in PYTHON_TAB_TITLES: + continue + 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..41f452e0360 --- /dev/null +++ b/utils/g.md2man/gmd.py @@ -0,0 +1,509 @@ +# 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 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] = value.strip('"') + 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_html_comments(lines): + """Remove HTML comments, except inside fenced code blocks.""" + 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 = re.sub(r"", "", line) + start = line.find("`` 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.""" + """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 @@ -201,11 +222,7 @@ def strip_html_comments(lines): continue line = line[end + 3 :] in_comment = False - line = re.sub(r"", "", line) - start = line.find(" 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"] From f6268239d4f3936ca781b911f849bf1a1d683100 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Sat, 13 Jun 2026 23:05:57 -0400 Subject: [PATCH 3/7] CodeQL Incomplete URL substring sanitization --- utils/g.md2man/tests/g_md2man_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/utils/g.md2man/tests/g_md2man_test.py b/utils/g.md2man/tests/g_md2man_test.py index 188803b2f82..df8717f3de1 100644 --- a/utils/g.md2man/tests/g_md2man_test.py +++ b/utils/g.md2man/tests/g_md2man_test.py @@ -234,9 +234,10 @@ def test_inline_emphasis(): 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 "a * literal " in nodes - assert "https://example.com" in nodes + assert nodes == ["a * literal ", "https://example.com"] def test_escaped_asterisk_does_not_pair_with_emphasis(): From 32c3d3f208c94754843dab4f09ad658742983a85 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Sat, 4 Jul 2026 00:19:43 -0400 Subject: [PATCH 4/7] address review --- include/Make/Grass.make | 1 - man/Makefile | 9 +- utils/g.md2man/g.md2man.py | 12 ++- utils/g.md2man/gmd.py | 134 ++++++++++++++++++++++---- utils/g.md2man/tests/g_md2man_test.py | 67 +++++++++++++ 5 files changed, 197 insertions(+), 26 deletions(-) diff --git a/include/Make/Grass.make b/include/Make/Grass.make index e50cb95dbfc..63c9895a3b9 100644 --- a/include/Make/Grass.make +++ b/include/Make/Grass.make @@ -98,7 +98,6 @@ 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/man/Makefile b/man/Makefile index 8bb2ee87c1e..c7255e9c13a 100644 --- a/man/Makefile +++ b/man/Makefile @@ -2,9 +2,12 @@ MODULE_TOPDIR = .. include $(MODULE_TOPDIR)/include/Make/Other.make -# keywords.md is a static stub for the MkDocs tags plugin and has no -# content to convert to a man page. -MANPAGES := $(filter-out $(MANDIR)/keywords.$(MANSECT),\ +# These 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. +MKDOCS_ONLY_MD := index keywords development_intro command_line_intro +MANPAGES := $(filter-out $(patsubst %,$(MANDIR)/%.$(MANSECT),$(MKDOCS_ONLY_MD)),\ $(patsubst $(MDDIR)/source/%.md,$(MANDIR)/%.$(MANSECT),$(wildcard $(MDDIR)/source/*.md))) DSTFILES := \ diff --git a/utils/g.md2man/g.md2man.py b/utils/g.md2man/g.md2man.py index 48ffca8e9b8..7723bb76ffb 100644 --- a/utils/g.md2man/g.md2man.py +++ b/utils/g.md2man/g.md2man.py @@ -28,7 +28,10 @@ import gmd CLI_TAB_TITLE = "Command line" -PYTHON_TAB_TITLES = ("Python (grass.tools)", "Python (grass.script)") +# 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): @@ -57,8 +60,13 @@ def expand_tabs(tabs): out = [] for _tag, attrs, body in tabs: title = dict(attrs).get("title") - if title in PYTHON_TAB_TITLES: + 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)) diff --git a/utils/g.md2man/gmd.py b/utils/g.md2man/gmd.py index c7e21d73b20..b6ce0f4e321 100644 --- a/utils/g.md2man/gmd.py +++ b/utils/g.md2man/gmd.py @@ -155,6 +155,19 @@ def emit_text(segment): 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). @@ -176,7 +189,7 @@ def parse_front_matter(lines): if item.strip() ] elif value: - meta[key] = value.strip('"') + meta[key] = unquote_yaml(value) i += 1 if i >= len(lines): # Unterminated block: treat the file as having no front matter. @@ -227,10 +240,52 @@ def strip_html_comments(lines): return out +RAW_BLOCK_OPEN_RE = re.compile(r"<(style|script)\b[^>]*>", re.IGNORECASE) +RAW_BLOCK_CLOSE_RE = re.compile(r"", re.IGNORECASE) + + +def strip_raw_html_blocks(lines): + """Remove \n\nVisible text.\n" result = convert(md, tmp_path) From b569c3ab9727542aa9dc7432f92386fc7b8a6d98 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Sat, 4 Jul 2026 13:26:14 -0400 Subject: [PATCH 7/7] fix the reverted chmod --- utils/g.md2man/g.md2man.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 utils/g.md2man/g.md2man.py diff --git a/utils/g.md2man/g.md2man.py b/utils/g.md2man/g.md2man.py old mode 100644 new mode 100755