Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/documentation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,15 @@ jobs:
cd "${MKDOCS_DIR}" || exit
mkdocs build

- name: Generate manuals sitemap
# List every built page, not only those in the site navigation, so
# the tool pages stay in the sitemap regardless of the site generator.
# Runs before libpython is moved in; Sphinx provides that sitemap.
run: |
python grass/utils/generate_sitemap.py \
--site-dir "${MKDOCS_DIR}/site" \
--site-url https://grass.osgeo.org/grass-stable/manuals/

- name: Build Sphinx documentation
run: |
pip install -r "grass/python/grass/docs/requirements.txt"
Expand Down
7 changes: 1 addition & 6 deletions man/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,7 @@ if(NOT WITH_FHS)
add_custom_target(
copy_mkdocs_files
COMMAND
${CMAKE_COMMAND} -E make_directory ${mkdocs_dir}/scripts
${mkdocs_dir}/overrides/partials
${CMAKE_COMMAND} -E make_directory ${mkdocs_dir}/overrides/partials
${mkdocs_dir}/overrides/fragments/tags/default
COMMAND
${CMAKE_COMMAND} -E copy_if_different
Expand All @@ -147,10 +146,6 @@ if(NOT WITH_FHS)
${CMAKE_CURRENT_SOURCE_DIR}/grass_logo.svg
${CMAKE_CURRENT_SOURCE_DIR}/grass_icon.png
${CMAKE_CURRENT_SOURCE_DIR}/favicon.ico ${mkdocs_dir}/source
COMMAND
${CMAKE_COMMAND} -E copy_if_different
${CMAKE_CURRENT_SOURCE_DIR}/mkdocs/scripts/hook_list_scripts.py
${mkdocs_dir}/scripts
COMMAND
${CMAKE_COMMAND} -E copy_if_different
${CMAKE_CURRENT_SOURCE_DIR}/mkdocs/overrides/partials/copyright.html
Expand Down
7 changes: 0 additions & 7 deletions man/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ DSTFILES := \
$(MDDIR)/source/grass_icon.png \
$(MDDIR)/source/favicon.ico \
$(MDDIR)/source/grassdocs.css \
$(MDDIR)/scripts/hook_list_scripts.py \
$(MDDIR)/source/index.md \
$(MDDIR)/overrides/main.html \
$(MDDIR)/overrides/partials/copyright.html \
Expand Down Expand Up @@ -281,12 +280,6 @@ $(MDDIR)/source/development_intro.md: mkdocs/docs/development_intro.md
$(MDDIR)/source/command_line_intro.md: mkdocs/docs/command_line_intro.md
$(INSTALL_DATA) $< $@

$(MDDIR)/scripts/hook_list_scripts.py: mkdocs/scripts/hook_list_scripts.py | $(MDDIR)/scripts
$(INSTALL_DATA) $< $@

$(MDDIR)/scripts:
$(MKDIR) $@

$(MDDIR)/overrides/main.html: mkdocs/overrides/main.html | $(MDDIR)/overrides
$(INSTALL_DATA) $< $@

Expand Down
4 changes: 0 additions & 4 deletions man/mkdocs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,6 @@ extra:
grass: grass
grass_addons: grass-addons

# Hooks
hooks:
- scripts/hook_list_scripts.py

# Custom CSS
extra_css:
- grassdocs.css
Expand Down
44 changes: 19 additions & 25 deletions man/mkdocs/overrides/partials/actions.html
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
{% if page.edit_url %}

{% set toolname = page.file.src_path.split("/")[-1] | replace(".md", "") %}
{# Default to the normal edit URL #}
{% set ghpath = github_path(toolname, config.extra.source_docs) %}
{% if ghpath %}
{% set custom_edit_url = page.edit_url | replace(page.edit_url, ghpath ~ "/" ~ toolname ~ ".md") %}
{% if "content.action.edit" in features %}
<a href="{{ custom_edit_url }}" title="{{ lang.t('action.edit') }}" class="md-content__button md-icon">
{% set icon = config.theme.icon.edit or "material/file-edit-outline" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</a>
{% endif %}

{% if "content.action.view" in features %}
{% if "/blob/" in custom_edit_url %}
{% set part = "/blob/" %}
{% else %}
{% set part = "/edit/" %}
{% endif %}
<a href="{{ custom_edit_url | replace(part, '/tree/') }}" title="{{ lang.t('action.view') }}" class="md-content__button md-icon">
{% set icon = config.theme.icon.view or "material/file-eye-outline" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</a>
{% endif %}
{#
The generated pages carry the URL of their Markdown source in the front
matter (source_edit_url, written by utils/mkmarkdown.py), so the edit and
view buttons can point to the tool directory in the repository rather than
to the generated documentation tree, without any Python in the site build.
#}
{% if page.meta and page.meta.source_edit_url %}
{% set edit_url = page.meta.source_edit_url %}
{% if "content.action.edit" in features %}
<a href="{{ edit_url }}" title="{{ lang.t('action.edit') }}" class="md-content__button md-icon">
{% set icon = config.theme.icon.edit or "material/file-edit-outline" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</a>
{% endif %}
{% if "content.action.view" in features %}
<a href="{{ edit_url | replace('/edit/', '/tree/') }}" title="{{ lang.t('action.view') }}" class="md-content__button md-icon">
{% set icon = config.theme.icon.view or "material/file-eye-outline" %}
{% include ".icons/" ~ icon ~ ".svg" %}
</a>
{% endif %}
{% endif %}
83 changes: 0 additions & 83 deletions man/mkdocs/scripts/hook_list_scripts.py

This file was deleted.

82 changes: 82 additions & 0 deletions utils/generate_sitemap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env python3

# Copyright (C) 2026 by the GRASS Development Team
# SPDX-License-Identifier: GPL-2.0-or-later

"""Generate a sitemap listing every HTML page of a built documentation site.

Static site generators list only the pages they know about, which for
Zensical means the pages in the navigation. The GRASS manual keeps the
hundreds of tool pages out of the navigation, so the sitemap is generated
from the built pages instead.
"""

import argparse
import datetime
from pathlib import Path
from xml.sax.saxutils import escape


def page_paths(site_dir: Path) -> list[str]:
"""Return the site-relative paths of all HTML pages, sorted.

The error page is not a page to be indexed and is left out.
"""
return sorted(
path.relative_to(site_dir).as_posix()
for path in site_dir.rglob("*.html")
if path.relative_to(site_dir).as_posix() != "404.html"
)


def sitemap_xml(site_url: str, paths: list[str], lastmod: datetime.date) -> str:
"""Return the sitemap document for the given page paths."""
base = site_url.rstrip("/") + "/"
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
]
for path in paths:
lines += [
" <url>",
f" <loc>{escape(base + path)}</loc>",
f" <lastmod>{lastmod.isoformat()}</lastmod>",
" </url>",
]
lines.append("</urlset>")
return "\n".join(lines) + "\n"


def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--site-dir",
type=Path,
required=True,
help="directory with the built HTML pages",
)
parser.add_argument(
"--site-url",
required=True,
help="URL the site directory is published at",
)
parser.add_argument(
"--output",
type=Path,
help="output file (default: sitemap.xml in the site directory)",
)
args = parser.parse_args()

if not args.site_dir.is_dir():
parser.error(f"{args.site_dir} is not a directory")
output = args.output or args.site_dir / "sitemap.xml"

paths = page_paths(args.site_dir)
output.write_text(
sitemap_xml(args.site_url, paths, datetime.date.today()), encoding="utf-8"
)
print(f"Wrote {len(paths)} pages to {output}")


if __name__ == "__main__":
main()
29 changes: 26 additions & 3 deletions utils/mkmarkdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,11 @@ def modify_keyword_links(text, prefix):
return text.replace(keywords_content, modified_keywords_content)


def merge_md_files(md1, md2, content_modifier1, content_modifier2):
"""Merge two markdown files by concatenating their YAML headers and content."""
def merge_md_files(md1, md2, content_modifier1, content_modifier2, extra_yaml=None):
"""Merge two markdown files by concatenating their YAML headers and content.

The optional extra_yaml is appended to the combined YAML header.
"""
yaml1, content1 = extract_yaml_header(md1)
yaml2, content2 = extract_yaml_header(md2)

Expand All @@ -187,6 +190,8 @@ def merge_md_files(md1, md2, content_modifier1, content_modifier2):
yaml_items.append(yaml1)
if yaml2:
yaml_items.append(yaml2)
if extra_yaml:
yaml_items.append(extra_yaml)
if yaml_items:
# Add YAML header only when non-empty.
combined_yaml = "\n".join(yaml_items)
Expand All @@ -204,6 +209,18 @@ def merge_md_files(md1, md2, content_modifier1, content_modifier2):
return result


def source_edit_url(url_source, pgm):
"""Return the URL for editing the tool's Markdown source on GitHub.

The source URL points to the tool directory in the repository tree,
so the edit URL is the same path in the edit view plus the file name.
Returns None when the source location is unknown.
"""
if not url_source:
return None
return f"{url_source.rstrip('/').replace('/tree/', '/edit/', 1)}/{pgm}.md"


def main():
pgm = sys.argv[1]

Expand All @@ -226,6 +243,12 @@ def modify_generated(text):
prefix = os.getenv("HTML_PAGE_FOOTER_PAGES_PATH") or None
return modify_keyword_links(text, prefix=prefix)

url_source, url_log, date_tag = parse_source(pgm)
# The edit URL goes into the front matter so that the website templates
# can link to the source file without any Python hook in the site build.
edit_url = source_edit_url(url_source, pgm)
extra_yaml = f"source_edit_url: {edit_url}" if edit_url else None

# process header/usage generated by --md-description
tool_generated = read_file(tmp_file)
# process body
Expand All @@ -236,14 +259,14 @@ def modify_generated(text):
source_code,
content_modifier1=modify_generated,
content_modifier2=None,
extra_yaml=extra_yaml,
)
for item in items:
if not item:
continue
sys.stdout.write(item)

# process footer
url_source, url_log, date_tag = parse_source(pgm)
sys.stdout.write(
sourcecode.substitute(
URL_SOURCE=url_source,
Expand Down
Loading
Loading