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. `+)"
+ r"|(?P!\[(?P
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
must not empty + # the parser's tag stack and silently drop the first paragraph. + md = "
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"]