Skip to content

docs: Accept backslash-escaped less-than sign in mkdocs - #8076

Open
wenzeslaus wants to merge 7 commits into
OSGeo:mainfrom
wenzeslaus:mkdocs-escape-lt
Open

wenzeslaus wants to merge 7 commits into
OSGeo:mainfrom
wenzeslaus:mkdocs-escape-lt

Conversation

@wenzeslaus

@wenzeslaus wenzeslaus commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

The MkDocs manual shows a stray backslash wherever the Markdown source tries to escape a less-than sign (<), for example in the r.watershed author line \<markus.metz.giswork at gmail.com\> (see screenshot below).

The escape is valid CommonMark and renders fine on GitHub, but Python-Markdown used by MkDocs does not treat < as escapable. While this is simply a choice one way or the other (based on the selected standard), I suggest aligning with GitHub here, although my general preference would be a simpler text (no slash). This adds a Python-Markdown extension which adds < to the escapable characters, which fixes core and addon pages without editing the sources. The extension works with both MkDocs and Zensical.

This is the first custom Markdown extension (a new hook for this would not be the first hook, but hooks are supported only in MkDocs).

The issue in rendered documentation (screenshot)

image

Markdown as rendered by GitHub (screenshot)

image
Details (AI generated)

Cause

The \< comes from the pandoc HTML-to-Markdown conversion (utils/grass_html2md.sh). Pandoc writes CommonMark/GFM, where any ASCII punctuation can be escaped with a backslash. MkDocs uses Python-Markdown, which follows the original Markdown rules. Those rules list \, `, *, _, {}, [], (), #, +, -, ., ! (Python-Markdown adds >), but not <. The maintainer confirms that only this subset is escapable (Python-Markdown/markdown#1464). So \<x\> renders as \<x>.

GitHub renders these files correctly, and so does g.md2man, which already treats \< as an escape (utils/g.md2man/gmd.py). Only the HTML manual is affected.

Change

The new module man/mkdocs/scripts/grass_markdown.py is a small Python-Markdown extension (EscapeLessThanExtension) that adds < to md.ESCAPED_CHARS. Python-Markdown provides this mechanism for extensions, and its built-in tables extension uses it to add |, the same way smarty adds the quote characters. The extension is listed under markdown_extensions in mkdocs.yml and installed by both the Makefile and the CMake build. The site generator imports it by name, so the build commands (Makefile, CMake, documentation workflow) put the scripts directory on PYTHONPATH. Without it, the build stops with an error rather than rendering the backslashes.

The extension is not a hook, so it does not conflict with the move to Zensical (#7938), which has no Python hooks. Zensical 0.0.71 uses Python-Markdown, reads markdown_extensions from mkdocs.yml, and renders \< with the same stray backslash as MkDocs, so the fix is still needed after the switch. Zensical loads the extension as it is.

Addon docs are built with the core man/mkdocs config (.github/workflows/documentation.yml), so addon pages are fixed as well.

Tested with full local builds of the manual, with MkDocs (make build-mkdocs with the pinned requirements) and with Zensical 0.0.71 (on the same tree, without the actions.html override which depends on the other hook). In both, no page contains a backslash before an escaped less-than sign. Results from a minimal project with mkdocs 1.6.1 and Python-Markdown 3.11:

Source Before After
\<markus.metz.giswork at gmail.com\> \<markus.metz.giswork at gmail.com> <markus.metz.giswork at gmail.com>
x \< 5 x \< 5 x < 5
\<x\> in a table cell \<x> <x>
`code \<x\>` code \<x\> unchanged, code spans are literal

Affected lines

These are lines that render a stray backslash with stock Python-Markdown. There are no cases inside code spans or fenced code.

  • grass: 46 lines in 30 files
  • grass-addons: 31 lines in 18 files

Examples:

File Source
raster/r.watershed/front/r.watershed.md \<markus.metz.giswork at gmail.com\>
lib/pngdriver/pngdriver.md Per Henrik Johansen \<*phj (at) norgit.no*\>
lib/cairodriver/cairodriver.md Lars Ahlzen \<*lars (at) ahlzen.com*\>
vector/v.random/v.random.md [\<darrell@mccauley-usa.com\>](mailto:darrell@mccauley-usa.com)
vector/v.clean/v.clean.md length is shorter than *thresh* or *thresh* \< 0
vector/v.surf.idw/v.surf.idw.md data points for 0 \< *p* \< 1
general/g.parser/g.parser.md "GIS_FLAG\_\<NAME\>" for flags and "GIS_OPT\_\<NAME\>"
lib/init/grass.md \[\[\[**\<GISDBASE\>/**\]**\<PROJECT\>/**\]
db/drivers/odbc/grass-odbc.md | GRASS module \<-\> | \<--\> | ODBC Interface |
ps/ps.map/ps.map.md "\<raster name\> in mapset \<mapset name\>"
raster/r.thin/r.thin.md if the object is \<= n pixels thick
addons v.feature.algebra Christoph Simon \<ciccio at kiosknet com.br\>
addons v.in.survey *ERROR: Vector layer \<layer\> exists. ...*
addons r.bioclim The names of the output maps are \<output\>bio01, \<output\>bio02,
addons r.bearing.distance 0; 0 \< θ ≤ 22.5; 22.5 \< θ ≤ 45; ...
addons i.sentinel.preproc (15.00 \> lat \<= 45.00), Tropical (-15.00 \> lat \<= 15.00)

Alternatives considered

  1. Fix the source lines (46 in grass, 31 in addons). I prepared and render-tested a patch for this. Each line can be changed in one of these ways:

    • &lt; entity: \<markus...\> becomes &lt;markus...&gt;. This works in every renderer, but HTML entities make the Markdown harder to read for a character this common.
    • Bare < where it cannot start an HTML tag, as the original Markdown rules suggest. This works when < is followed by a space, digit, = or - (x < 5, <= 0.5, <->). It does not work for <name>, <output>bio01 or <user at host>, because Python-Markdown treats those as raw HTML and they disappear from the page.
    • Code spans for placeholders, such as `<layer>` or `opt_<name>=<value>`. These are often better semantically, but it is a larger editorial change.

    Each of these only fixes the existing lines. New docs written for GitHub/CommonMark, or converted with pandoc, would bring \< back unless a lint check is added.

  2. pymdownx.escapeall (suggested in A line ending that is preceded by a backslash is not parsed as a hard line break Python-Markdown/markdown#1464). This makes every backslash an escape, which goes further than CommonMark and breaks existing docs:

    • %APPDATA%\postgresql\pgpass.conf in grass-pg.md would render as %APPDATA%postgresqlpgpass.conf.
    • C:\\Program Files\\Java\\... in addons r.maxent.setup and m.printws would lose its backslashes.
    • It would likely interfere with the LaTeX in addons r.runoff and r.timeofconcentration.
  3. Post-process pandoc output in utils/grass_html2md.sh. The conversion is already done, so this would only affect future conversions and would not fix the existing files.

  4. MkDocs hook (the first version of this PR). The hook registered the same extension and needed no PYTHONPATH setting, because MkDocs loads hooks by file path. Zensical has no Python hooks, so it would not survive the switch (docs: Make edit links and sitemap independent of the site generator #7938).

  5. Replace \< with &lt; when the pages are generated, in utils/mkmarkdown.py. This works with any Markdown renderer and leaves the sources unchanged, but it needs its own detection of code spans and fenced code, and it covers only pages generated by that script.

  6. Wait for the switch to Zensical. Zensical 0.0.71 uses Python-Markdown and renders \< with the same stray backslash, so the switch alone does not fix it.

Suggested squash-merge commit message

docs: Accept backslash-escaped less-than sign in mkdocs
Markdown docs written for CommonMark (pandoc conversion, GitHub) use
\< to escape the less-than sign, e.g., in author emails as
\<user at example.com\> or in comparisons like x \< 0. Python-Markdown,
used by MkDocs, follows the original Markdown rules where < is not
escapable, so the backslash was rendered in the HTML manual.

Add a small Python-Markdown extension adding < to ESCAPED_CHARS, the
same mechanism the tables extension uses for |. This fixes the rendering
for core and addons without changing the sources. The extension is
listed in markdown_extensions, which both MkDocs and Zensical read, and
the build puts its directory on PYTHONPATH.

Written with the help of Claude Code.

The extension and this description were written with Claude Code (Claude Opus 5.5) and reviewed by the author of the PR.

Markdown docs written for CommonMark (pandoc conversion, GitHub) use
\< to escape the less-than sign, e.g., in author emails as
\<user at example.com\> or in comparisons like x \< 0. Python-Markdown,
used by MkDocs, follows the original Markdown rules where < is not
escapable, so the backslash was rendered in the HTML manual.

Add a MkDocs hook which registers a small Python-Markdown extension
adding < to ESCAPED_CHARS, the same mechanism the tables extension uses
for |. This fixes the rendering for core and addons without changing the
sources.

Written with the help of Claude Code.
The docstring shows CommonMark escapes like \<name\>. A raw string
lets the source show them as they are instead of doubled backslashes.

Written with the help of Claude Code.
The module docstring now describes the general purpose of the hook,
the CommonMark versus Python-Markdown difference in backslash escapes,
and the class docstring explains the less-than sign specifically. The
note about the tables extension is removed as it is not needed to
understand the code.

Written with the help of Claude Code.
The hook module is about backslash escapes in general, so name it
hook_escape.py rather than after the less-than sign only.

Written with the help of Claude Code.

@petrasovaa petrasovaa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems incompatible with moving to Zensical, see #7938.

Zensical, which the manual build is being prepared for, has no Python
hooks, but it uses Python-Markdown and reads markdown_extensions from
mkdocs.yml like MkDocs does. Zensical has the same issue with \< as
MkDocs, so the fix is still needed after the switch.

Register the escape as a Python-Markdown extension in
markdown_extensions instead of with a hook. The generator imports it by
name, so the MkDocs build commands in the Makefile, CMake, and the
documentation workflow put the scripts directory on PYTHONPATH.

Written with the help of Claude Code.
@github-actions github-actions Bot added the CI Continuous integration label Oct 19, 2026
@wenzeslaus

Copy link
Copy Markdown
Member Author

Zensical also uses Python-Markdown and shows the same stray backslashes, so I replaced the hook with a Python-Markdown extension, which both MkDocs and Zensical load unlike the hooks.

The module docstring described how the extension is loaded by the site
generators. That belongs to the commit and PR descriptions, so keep the
docstring to what the extension does.

Written with the help of Claude Code.

@petrasovaa petrasovaa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be merged before #7938, and that one needs to be adjusted ($(MDDIR)/scripts rule and the CMake make_directory for scripts must stay).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing SPDX header.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now a full three-row SPDX header is present.

Written with the help of Claude Code.
@wenzeslaus
wenzeslaus requested a review from petrasovaa October 27, 2026 12:42
@wenzeslaus
wenzeslaus enabled auto-merge (squash) October 27, 2026 12:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI Continuous integration CMake docs Python Related code is in Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants