mkhtml.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. #!/usr/bin/env python3
  2. ############################################################################
  3. #
  4. # MODULE: Builds manual pages
  5. # AUTHOR(S): Markus Neteler
  6. # Glynn Clements
  7. # Martin Landa <landa.martin gmail.com>
  8. # PURPOSE: Create HTML manual page snippets
  9. # COPYRIGHT: (C) 2007-2022 by Glynn Clements
  10. # and the GRASS Development Team
  11. #
  12. # This program is free software under the GNU General
  13. # Public License (>=v2). Read the file COPYING that
  14. # comes with GRASS for details.
  15. #
  16. #############################################################################
  17. import http
  18. import sys
  19. import os
  20. import string
  21. import re
  22. from datetime import datetime
  23. import locale
  24. import json
  25. import pathlib
  26. import shutil
  27. import subprocess
  28. import time
  29. try:
  30. # Python 2 import
  31. from HTMLParser import HTMLParser
  32. except ImportError:
  33. # Python 3 import
  34. from html.parser import HTMLParser
  35. from six.moves.urllib import request as urlrequest
  36. from six.moves.urllib.error import HTTPError, URLError
  37. try:
  38. import urlparse
  39. except ImportError:
  40. import urllib.parse as urlparse
  41. try:
  42. import grass.script as gs
  43. except ImportError:
  44. # During compilation GRASS GIS
  45. _ = str
  46. class gs:
  47. def warning(message):
  48. pass
  49. def fatal(message):
  50. pass
  51. HEADERS = {
  52. "User-Agent": "Mozilla/5.0",
  53. }
  54. HTTP_STATUS_CODES = list(http.HTTPStatus)
  55. if sys.version_info[0] == 2:
  56. PY2 = True
  57. else:
  58. PY2 = False
  59. if not PY2:
  60. unicode = str
  61. grass_version = os.getenv("VERSION_NUMBER", "unknown")
  62. trunk_url = ""
  63. addons_url = ""
  64. if grass_version != "unknown":
  65. major, minor, patch = grass_version.split(".")
  66. grass_git_branch = "releasebranch_{major}_{minor}".format(
  67. major=major,
  68. minor=minor,
  69. )
  70. base_url = "https://github.com/OSGeo"
  71. trunk_url = "{base_url}/grass/tree/{branch}/".format(
  72. base_url=base_url, branch=grass_git_branch
  73. )
  74. addons_url = "{base_url}/grass-addons/tree/grass{major}/".format(
  75. base_url=base_url, major=major
  76. )
  77. def _get_encoding():
  78. encoding = locale.getdefaultlocale()[1]
  79. if not encoding:
  80. encoding = "UTF-8"
  81. return encoding
  82. def decode(bytes_):
  83. """Decode bytes with default locale and return (unicode) string
  84. No-op if parameter is not bytes (assumed unicode string).
  85. :param bytes bytes_: the bytes to decode
  86. """
  87. if isinstance(bytes_, unicode):
  88. return bytes_
  89. if isinstance(bytes_, bytes):
  90. enc = _get_encoding()
  91. return bytes_.decode(enc)
  92. return unicode(bytes_)
  93. def urlopen(url, *args, **kwargs):
  94. """Wrapper around urlopen. Same function as 'urlopen', but with the
  95. ability to define headers.
  96. """
  97. request = urlrequest.Request(url, headers=HEADERS)
  98. return urlrequest.urlopen(request, *args, **kwargs)
  99. def set_proxy():
  100. """Set proxy"""
  101. proxy = os.getenv("GRASS_PROXY")
  102. if proxy:
  103. proxies = {}
  104. for ptype, purl in (p.split("=") for p in proxy.split(",")):
  105. proxies[ptype] = purl
  106. urlrequest.install_opener(
  107. urlrequest.build_opener(urlrequest.ProxyHandler(proxies))
  108. )
  109. set_proxy()
  110. def download_git_commit(url, response_format, *args, **kwargs):
  111. """Download module/addon last commit from GitHub API
  112. :param str url: url address
  113. :param str response_format: content type
  114. :return urllib.request.urlopen or None response: response object or
  115. None
  116. """
  117. try:
  118. response = urlopen(url, *args, **kwargs)
  119. if not response.code == 200:
  120. index = HTTP_STATUS_CODES.index(response.code)
  121. desc = HTTP_STATUS_CODES[index].description
  122. gs.fatal(
  123. _(
  124. "Download commit from <{url}>, return status code "
  125. "{code}, {desc}".format(
  126. url=url,
  127. code=response.code,
  128. desc=desc,
  129. ),
  130. ),
  131. )
  132. if response_format not in response.getheader("Content-Type"):
  133. gs.fatal(
  134. _(
  135. "Wrong downloaded commit file format. "
  136. "Check url <{url}>. Allowed file format is "
  137. "{response_format}.".format(
  138. url=url,
  139. response_format=response_format,
  140. ),
  141. ),
  142. )
  143. return response
  144. except HTTPError as err:
  145. gs.warning(
  146. _(
  147. "The download of the commit from the GitHub API "
  148. "server wasn't successful, <{}>. Commit and commit "
  149. "date will not be included in the <{}> addon html manual "
  150. "page.".format(err.msg, pgm)
  151. ),
  152. )
  153. except URLError:
  154. gs.warning(
  155. _(
  156. "Download file from <{url}>, failed. Check internet "
  157. "connection. Commit and commit date will not be included "
  158. "in the <{pgm}> addon manual page.".format(url=url, pgm=pgm)
  159. ),
  160. )
  161. def get_last_git_commit(src_dir, is_addon, addon_path):
  162. """Get last module/addon git commit
  163. :param str src_dir: module/addon source dir
  164. :param bool is_addon: True if it is addon
  165. :param str addon_path: addon path
  166. :return dict git_log: dict with key commit and date, if not
  167. possible download commit from GitHub API server
  168. values of keys have "unknown" string
  169. """
  170. unknown = "unknown"
  171. git_log = {"commit": unknown, "date": unknown}
  172. datetime_format = "%A %b %d %H:%M:%S %Y" # e.g. Sun Jan 16 23:09:35 2022
  173. if is_addon:
  174. grass_addons_url = (
  175. "https://api.github.com/repos/osgeo/grass-addons/commits?path={path}"
  176. "&page=1&per_page=1&sha=grass{major}".format(
  177. path=addon_path,
  178. major=major,
  179. )
  180. ) # sha=git_branch_name
  181. else:
  182. core_module_path = os.path.join(
  183. *(set(src_dir.split(os.path.sep)) ^ set(topdir.split(os.path.sep)))
  184. )
  185. grass_modules_url = (
  186. "https://api.github.com/repos/osgeo/grass/commits?path={path}"
  187. "&page=1&per_page=1&sha={branch}".format(
  188. branch=grass_git_branch,
  189. path=core_module_path,
  190. )
  191. ) # sha=git_branch_name
  192. if shutil.which("git"):
  193. if os.path.exists(src_dir):
  194. git_log["date"] = time.ctime(os.path.getmtime(src_dir))
  195. stdout, stderr = subprocess.Popen(
  196. args=["git", "log", "-1", src_dir],
  197. stdout=subprocess.PIPE,
  198. stderr=subprocess.PIPE,
  199. ).communicate()
  200. stdout = decode(stdout)
  201. stderr = decode(stderr)
  202. if stderr and "fatal: not a git repository" in stderr:
  203. response = download_git_commit(
  204. url=grass_addons_url if is_addon else grass_modules_url,
  205. response_format="application/json",
  206. )
  207. if response:
  208. commit = json.loads(response.read())
  209. if commit:
  210. git_log["commit"] = commit[0]["sha"]
  211. git_log["date"] = datetime.strptime(
  212. commit[0]["commit"]["author"]["date"],
  213. "%Y-%m-%dT%H:%M:%SZ",
  214. ).strftime(datetime_format)
  215. else:
  216. if stdout:
  217. commit = stdout.splitlines()
  218. git_log["commit"] = commit[0].split(" ")[-1]
  219. commit_date = commit[2].lstrip("Date:").strip()
  220. git_log["date"] = commit_date.rsplit(" ", 1)[0]
  221. return git_log
  222. html_page_footer_pages_path = (
  223. os.getenv("HTML_PAGE_FOOTER_PAGES_PATH")
  224. if os.getenv("HTML_PAGE_FOOTER_PAGES_PATH")
  225. else ""
  226. )
  227. pgm = sys.argv[1]
  228. src_file = "%s.html" % pgm
  229. tmp_file = "%s.tmp.html" % pgm
  230. header_base = """<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
  231. <html>
  232. <head>
  233. <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
  234. <title>${PGM} - GRASS GIS Manual</title>
  235. <meta name="Author" content="GRASS Development Team">
  236. <meta name="description" content="${PGM}: ${PGM_DESC}">
  237. <link rel="stylesheet" href="grassdocs.css" type="text/css">
  238. </head>
  239. <body bgcolor="white">
  240. <div id="container">
  241. <a href="index.html"><img src="grass_logo.png" alt="GRASS logo"></a>
  242. <hr class="header">
  243. """
  244. header_nopgm = """<h2>${PGM}</h2>
  245. """
  246. header_pgm = """<h2>NAME</h2>
  247. <em><b>${PGM}</b></em>
  248. """
  249. header_pgm_desc = """<h2>NAME</h2>
  250. <em><b>${PGM}</b></em> - ${PGM_DESC}
  251. """
  252. sourcecode = string.Template(
  253. """<h2>SOURCE CODE</h2>
  254. <p>
  255. Available at:
  256. <a href="${URL_SOURCE}">${PGM} source code</a>
  257. (<a href="${URL_LOG}">history</a>)
  258. </p>
  259. <p>
  260. ${DATE_TAG}
  261. </p>
  262. """
  263. )
  264. footer_index = string.Template(
  265. """<hr class="header">
  266. <p>
  267. <a href="index.html">Main index</a> |
  268. <a href="${HTML_PAGE_FOOTER_PAGES_PATH}${INDEXNAME}.html">${INDEXNAMECAP} index</a> |
  269. <a href="${HTML_PAGE_FOOTER_PAGES_PATH}topics.html">Topics index</a> |
  270. <a href="${HTML_PAGE_FOOTER_PAGES_PATH}keywords.html">Keywords index</a> |
  271. <a href="${HTML_PAGE_FOOTER_PAGES_PATH}graphical_index.html">Graphical index</a> |
  272. <a href="${HTML_PAGE_FOOTER_PAGES_PATH}full_index.html">Full index</a>
  273. </p>
  274. <p>
  275. &copy; 2003-${YEAR}
  276. <a href="https://grass.osgeo.org">GRASS Development Team</a>,
  277. GRASS GIS ${GRASS_VERSION} Reference Manual
  278. </p>
  279. </div>
  280. </body>
  281. </html>
  282. """
  283. )
  284. footer_noindex = string.Template(
  285. """<hr class="header">
  286. <p>
  287. <a href="index.html">Main index</a> |
  288. <a href="${HTML_PAGE_FOOTER_PAGES_PATH}topics.html">Topics index</a> |
  289. <a href="${HTML_PAGE_FOOTER_PAGES_PATH}keywords.html">Keywords index</a> |
  290. <a href="${HTML_PAGE_FOOTER_PAGES_PATH}graphical_index.html">Graphical index</a> |
  291. <a href="${HTML_PAGE_FOOTER_PAGES_PATH}full_index.html">Full index</a>
  292. </p>
  293. <p>
  294. &copy; 2003-${YEAR}
  295. <a href="https://grass.osgeo.org">GRASS Development Team</a>,
  296. GRASS GIS ${GRASS_VERSION} Reference Manual
  297. </p>
  298. </div>
  299. </body>
  300. </html>
  301. """
  302. )
  303. def read_file(name):
  304. try:
  305. f = open(name, "rb")
  306. s = f.read()
  307. f.close()
  308. if PY2:
  309. return s
  310. else:
  311. return decode(s)
  312. except IOError:
  313. return ""
  314. def create_toc(src_data):
  315. class MyHTMLParser(HTMLParser):
  316. def __init__(self):
  317. HTMLParser.__init__(self)
  318. self.reset()
  319. self.idx = 1
  320. self.tag_curr = ""
  321. self.tag_last = ""
  322. self.process_text = False
  323. self.data = []
  324. self.tags_allowed = ("h1", "h2", "h3")
  325. self.tags_ignored = "img"
  326. self.text = ""
  327. def handle_starttag(self, tag, attrs):
  328. if tag in self.tags_allowed:
  329. self.process_text = True
  330. self.tag_last = self.tag_curr
  331. self.tag_curr = tag
  332. def handle_endtag(self, tag):
  333. if tag in self.tags_allowed:
  334. self.data.append((tag, "%s_%d" % (tag, self.idx), self.text))
  335. self.idx += 1
  336. self.process_text = False
  337. self.text = ""
  338. self.tag_curr = self.tag_last
  339. def handle_data(self, data):
  340. if not self.process_text:
  341. return
  342. if self.tag_curr in self.tags_allowed or self.tag_curr in self.tags_ignored:
  343. self.text += data
  344. else:
  345. self.text += "<%s>%s</%s>" % (self.tag_curr, data, self.tag_curr)
  346. # instantiate the parser and fed it some HTML
  347. parser = MyHTMLParser()
  348. parser.feed(src_data)
  349. return parser.data
  350. def escape_href(label):
  351. # remove html tags
  352. label = re.sub("<[^<]+?>", "", label)
  353. # fix &nbsp;
  354. label = label.replace("&nbsp;", "")
  355. # fix "
  356. label = label.replace('"', "")
  357. # replace space with underscore + lower
  358. return label.replace(" ", "-").lower()
  359. def write_toc(data):
  360. if not data:
  361. return
  362. fd = sys.stdout
  363. fd.write('<div class="toc">\n')
  364. fd.write('<h4 class="toc">Table of contents</h4>\n')
  365. fd.write('<ul class="toc">\n')
  366. first = True
  367. has_h2 = False
  368. in_h3 = False
  369. indent = 4
  370. for tag, href, text in data:
  371. if tag == "h3" and not in_h3 and has_h2:
  372. fd.write('\n%s<ul class="toc">\n' % (" " * indent))
  373. indent += 4
  374. in_h3 = True
  375. elif not first:
  376. fd.write("</li>\n")
  377. if tag == "h2":
  378. has_h2 = True
  379. if in_h3:
  380. indent -= 4
  381. fd.write("%s</ul></li>\n" % (" " * indent))
  382. in_h3 = False
  383. text = text.replace("\xa0", " ")
  384. fd.write(
  385. '%s<li class="toc"><a href="#%s" class="toc">%s</a>'
  386. % (" " * indent, escape_href(text), text)
  387. )
  388. first = False
  389. fd.write("</li>\n</ul>\n")
  390. fd.write("</div>\n")
  391. def update_toc(data):
  392. ret_data = []
  393. pat = re.compile(r"(<(h[2|3])>)(.+)(</h[2|3]>)")
  394. idx = 1
  395. for line in data.splitlines():
  396. if pat.search(line):
  397. xline = pat.split(line)
  398. line = (
  399. xline[1]
  400. + '<a name="%s">' % escape_href(xline[3])
  401. + xline[3]
  402. + "</a>"
  403. + xline[4]
  404. )
  405. idx += 1
  406. ret_data.append(line)
  407. return "\n".join(ret_data)
  408. def get_addon_path():
  409. """Check if pgm is in the addons list and get addon path
  410. return: pgm path if pgm is addon else None
  411. """
  412. addon_base = os.getenv("GRASS_ADDON_BASE")
  413. if addon_base:
  414. # addons_paths.json is file created during install extension
  415. # check get_addons_paths() function in the g.extension.py file
  416. addons_file = "addons_paths.json"
  417. addons_paths = os.path.join(addon_base, addons_file)
  418. if not os.path.exists(addons_paths):
  419. # Compiled addon has own dir e.g. ~/.grass8/addons/db.join/
  420. # with bin/ docs/ etc/ scripts/ subdir, required for compilation
  421. # addons on osgeo lxd container server and generation of
  422. # modules.xml file (build-xml.py script), when addons_paths.json
  423. # file is stored one level dir up
  424. addons_paths = os.path.join(
  425. os.path.abspath(os.path.join(addon_base, "..")),
  426. addons_file,
  427. )
  428. if not os.path.exists(addons_paths):
  429. return
  430. with open(addons_paths) as f:
  431. addons_paths = json.load(f)
  432. for addon in addons_paths["tree"]:
  433. if pgm == pathlib.Path(addon["path"]).name:
  434. return addon["path"]
  435. # process header
  436. src_data = read_file(src_file)
  437. name = re.search("(<!-- meta page name:)(.*)(-->)", src_data, re.IGNORECASE)
  438. pgm_desc = "GRASS GIS Reference Manual"
  439. if name:
  440. pgm = name.group(2).strip().split("-", 1)[0].strip()
  441. name_desc = re.search(
  442. "(<!-- meta page name description:)(.*)(-->)", src_data, re.IGNORECASE
  443. )
  444. if name_desc:
  445. pgm_desc = name_desc.group(2).strip()
  446. desc = re.search("(<!-- meta page description:)(.*)(-->)", src_data, re.IGNORECASE)
  447. if desc:
  448. pgm = desc.group(2).strip()
  449. header_tmpl = string.Template(header_base + header_nopgm)
  450. else:
  451. if not pgm_desc:
  452. header_tmpl = string.Template(header_base + header_pgm)
  453. else:
  454. header_tmpl = string.Template(header_base + header_pgm_desc)
  455. if not re.search("<html>", src_data, re.IGNORECASE):
  456. tmp_data = read_file(tmp_file)
  457. """
  458. Adjusting keywords html pages paths if add-on html man page
  459. stored on the server
  460. """
  461. if html_page_footer_pages_path:
  462. new_keywords_paths = []
  463. orig_keywords_paths = re.search(
  464. r"<h[1-9]>KEYWORDS</h[1-9]>(.*?)<h[1-9]>",
  465. tmp_data,
  466. re.DOTALL,
  467. )
  468. if orig_keywords_paths:
  469. search_txt = 'href="'
  470. for i in orig_keywords_paths.group(1).split(","):
  471. if search_txt in i:
  472. index = i.index(search_txt) + len(search_txt)
  473. new_keywords_paths.append(
  474. i[:index] + html_page_footer_pages_path + i[index:],
  475. )
  476. if new_keywords_paths:
  477. tmp_data = tmp_data.replace(
  478. orig_keywords_paths.group(1),
  479. ",".join(new_keywords_paths),
  480. )
  481. if not re.search("<html>", tmp_data, re.IGNORECASE):
  482. sys.stdout.write(header_tmpl.substitute(PGM=pgm, PGM_DESC=pgm_desc))
  483. if tmp_data:
  484. for line in tmp_data.splitlines(True):
  485. if not re.search("</body>|</html>", line, re.IGNORECASE):
  486. sys.stdout.write(line)
  487. # create TOC
  488. write_toc(create_toc(src_data))
  489. # process body
  490. sys.stdout.write(update_toc(src_data))
  491. # if </html> is found, suppose a complete html is provided.
  492. # otherwise, generate module class reference:
  493. if re.search("</html>", src_data, re.IGNORECASE):
  494. sys.exit()
  495. index_names = {
  496. "d": "display",
  497. "db": "database",
  498. "g": "general",
  499. "i": "imagery",
  500. "m": "miscellaneous",
  501. "ps": "postscript",
  502. "p": "paint",
  503. "r": "raster",
  504. "r3": "raster3d",
  505. "s": "sites",
  506. "t": "temporal",
  507. "v": "vector",
  508. }
  509. def to_title(name):
  510. """Convert name of command class/family to form suitable for title"""
  511. if name == "raster3d":
  512. return "3D raster"
  513. elif name == "postscript":
  514. return "PostScript"
  515. else:
  516. return name.capitalize()
  517. index_titles = {}
  518. for key, name in index_names.items():
  519. index_titles[key] = to_title(name)
  520. # process footer
  521. index = re.search("(<!-- meta page index:)(.*)(-->)", src_data, re.IGNORECASE)
  522. if index:
  523. index_name = index.group(2).strip()
  524. if "|" in index_name:
  525. index_name, index_name_cap = index_name.split("|", 1)
  526. else:
  527. index_name_cap = to_title(index_name)
  528. else:
  529. mod_class = pgm.split(".", 1)[0]
  530. index_name = index_names.get(mod_class, "")
  531. index_name_cap = index_titles.get(mod_class, "")
  532. year = os.getenv("VERSION_DATE")
  533. if not year:
  534. year = str(datetime.now().year)
  535. # check the names of scripts to assign the right folder
  536. topdir = os.path.abspath(os.getenv("MODULE_TOPDIR"))
  537. curdir = os.path.abspath(os.path.curdir)
  538. if curdir.startswith(topdir + os.path.sep):
  539. source_url = trunk_url
  540. pgmdir = curdir.replace(topdir, "").lstrip(os.path.sep)
  541. else:
  542. # addons
  543. source_url = addons_url
  544. pgmdir = os.path.sep.join(curdir.split(os.path.sep)[-3:])
  545. url_source = ""
  546. addon_path = None
  547. if os.getenv("SOURCE_URL", ""):
  548. addon_path = get_addon_path()
  549. if addon_path:
  550. # Addon is installed from the local dir
  551. if os.path.exists(os.getenv("SOURCE_URL")):
  552. url_source = urlparse.urljoin(
  553. addons_url,
  554. addon_path,
  555. )
  556. else:
  557. url_source = urlparse.urljoin(
  558. os.environ["SOURCE_URL"].split("src")[0],
  559. addon_path,
  560. )
  561. else:
  562. url_source = urlparse.urljoin(source_url, pgmdir)
  563. if sys.platform == "win32":
  564. url_source = url_source.replace(os.path.sep, "/")
  565. if index_name:
  566. branches = "branches"
  567. tree = "tree"
  568. commits = "commits"
  569. if branches in url_source:
  570. url_log = url_source.replace(branches, commits)
  571. url_source = url_source.replace(branches, tree)
  572. else:
  573. url_log = url_source.replace(tree, commits)
  574. git_commit = get_last_git_commit(
  575. src_dir=curdir,
  576. addon_path=addon_path if addon_path else None,
  577. is_addon=True if addon_path else False,
  578. )
  579. if git_commit["commit"] == "unknown":
  580. date_tag = "Accessed: {date}".format(date=git_commit["date"])
  581. else:
  582. date_tag = "Latest change: {date} in commit: {commit}".format(
  583. date=git_commit["date"], commit=git_commit["commit"]
  584. )
  585. sys.stdout.write(
  586. sourcecode.substitute(
  587. URL_SOURCE=url_source,
  588. PGM=pgm,
  589. URL_LOG=url_log,
  590. DATE_TAG=date_tag,
  591. )
  592. )
  593. sys.stdout.write(
  594. footer_index.substitute(
  595. INDEXNAME=index_name,
  596. INDEXNAMECAP=index_name_cap,
  597. YEAR=year,
  598. GRASS_VERSION=grass_version,
  599. HTML_PAGE_FOOTER_PAGES_PATH=html_page_footer_pages_path,
  600. ),
  601. )
  602. else:
  603. sys.stdout.write(
  604. footer_noindex.substitute(
  605. YEAR=year,
  606. GRASS_VERSION=grass_version,
  607. HTML_PAGE_FOOTER_PAGES_PATH=html_page_footer_pages_path,
  608. ),
  609. )