Shields.py

Summary

Lines:

169 of 191 covered (88.5%)

Branches:

70 of 76 taken (92.1%), 0 lines partially

Coverage:

89.5%

Directory:

Sphinx

Missed lines:

357-366, 569-578, 590-603

Source

pyTooling/Sphinx/Shields.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2#             _____           _ _               ____        _     _                                                    #
3#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
7# |_|    |___/                          |___/        |_|                                                               #
8# ==================================================================================================================== #
9# Authors:                                                                                                             #
10#   Patrick Lehmann                                                                                                    #
11#                                                                                                                      #
12# License:                                                                                                             #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany                                                             #
15#                                                                                                                      #
16# Licensed under the Apache License, Version 2.0 (the "License");                                                      #
17# you may not use this file except in compliance with the License.                                                     #
18# You may obtain a copy of the License at                                                                              #
19#                                                                                                                      #
20#   http://www.apache.org/licenses/LICENSE-2.0                                                                         #
21#                                                                                                                      #
22# Unless required by applicable law or agreed to in writing, software                                                  #
23# distributed under the License is distributed on an "AS IS" BASIS,                                                    #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.                                             #
25# See the License for the specific language governing permissions and                                                  #
26# limitations under the License.                                                                                       #
27#                                                                                                                      #
28# SPDX-License-Identifier: Apache-2.0                                                                                  #
29# ==================================================================================================================== #
30"""
31A Sphinx directive rendering a project's badges.
32
33A landing page shows where a project lives, how it is licensed, whether it builds and where it is published, as a
34few rows of `shields.io <https://shields.io/>`__ badges. The directive states the project's coordinates as options
35and the badges as its content:
36
37.. code-block:: ReST
38
39   .. shields::
40      :github:                pyTooling/pyTooling
41      :pypi:                  pyTooling
42      :codacy:                08ef744c0b70490289712b02a7a4cebe
43      :source-license:        github:LICENSE.md
44      :documentation-license: CC-BY-4.0 github:doc/License.rst
45      :github-action:         Pipeline.yml@main
46      :documentation:         github-pages
47
48      github, src-license, ghp-doc, doc-license
49      pypi-tag, pypi-status, pypi-python
50      github-action, lib-status, codacy-quality, codacy-coverage, codecov-coverage
51
52**The content is the layout**: badges appear in the order written, and a new line starts a new row. A badge needs
53only the options its URLs are made of - :data:`SHIELDS` names them per badge - so a project states what its badges
54use and nothing else.
55
56.. rubric:: Links
57
58``:source-license:`` and ``:documentation-license:`` link to where the license is written:
59
60* ``github:<path>`` is a file in the repository ``:github:`` names, on its default branch.
61* ``http://…`` or ``https://…`` is used as written.
62
63.. rubric:: Licenses
64
65PyPI reports a package's license, and GitHub a repository's, so ``:source-license:`` needs only the link - the badge
66asks PyPI when ``:pypi:`` is stated, and GitHub otherwise. An SPDX expression before the link replaces what they
67report. Nothing reports a documentation's license, so ``:documentation-license:`` states it
68first. Both are parsed with :meth:`~pyTooling.Licensing.LicenseExpression.Parse`, so a license outside the SPDX list
69is written as ``LicenseRef-<name>``.
70
71.. rubric:: HTML and LaTeX
72
73Both variants are emitted, each wrapped in an :rst:dir:`only` node: HTML embeds the SVG from ``img.shields.io``,
74LaTeX the PNG from ``raster.shields.io``, because a PDF cannot embed an SVG.
75
76.. seealso::
77
78   :ref:`DIR/Shields`
79      |rarr| The directive's options and badges, with a rendered example.
80   :mod:`pyTooling.Sphinx`
81      |rarr| The extension this belongs to, and what else it brings.
82"""
83from typing                import Any, Iterable, Optional as Nullable
84from urllib.parse          import quote
85
86from docutils              import nodes
87from sphinx.addnodes       import only
88
89from pyTooling.Common      import getFullyQualifiedName
90from pyTooling.Decorators  import export, readonly
91from pyTooling.Licensing   import BaseLicense, LicenseExpression, LicenseExpressionError
92from pyTooling.MetaClasses import ExtendedType
93from pyTooling.Sphinx      import BaseDirective, SphinxExtensionError, strip
94
95
96__all__ = ["SHIELDS_SERVICE_SVG", "SHIELDS_SERVICE_PNG", "BADGE_HEIGHT", "SHIELDS"]
97
98SHIELDS_SERVICE_SVG = "https://img.shields.io"
99"""Host serving a badge as SVG, which is what an HTML build embeds."""
100
101SHIELDS_SERVICE_PNG = "https://raster.shields.io"
102"""Host serving the same badge rasterized, which is what a LaTeX build needs - a PDF cannot embed the SVG."""
103
104BADGE_HEIGHT = 22
105"""Height every badge is scaled to, in pixels."""
106
107
108@export
109class Shield(metaclass=ExtendedType, slots=True):
110	"""
111	One badge: where its image comes from, what it links to, what a screen reader is told, and which of the
112	directive's options it is made of.
113
114	The image is stated as the part of the URL **after** the host, because the host is the only difference between
115	the SVG an HTML build embeds and the PNG a LaTeX build needs. Image and target are formatted from the settings the
116	directive derives from its options, so a placeholder such as ``{GitHubOrganization}`` is filled per page.
117	"""
118
119	_alternativeText: str              #: What the image says when it can't be shown.
120	_path:            str              #: Path and query on the badge host, with placeholders to format.
121	_target:          Nullable[str]    #: What the badge links to; ``None`` for a badge that links nowhere.
122	_options:         tuple[str, ...]  #: Names of the directive's options the badge is made of.
123
124	def __init__(
125		self,
126		alternativeText: str,
127		path: str,
128		target: Nullable[str] = None,
129		options: Nullable[Iterable[str]] = None
130	) -> None:
131		"""
132		Describe a badge.
133
134		:param alternativeText: What the image says when it can't be shown.
135		:param path:            Path and query on the badge host, with placeholders to format.
136		:param target:          Optional, what the badge links to.
137		:param options:         Optional, names of the directive's options the badge is made of.
138		:raises ValueError:     If parameter 'alternativeText' is None.
139		:raises TypeError:      If parameter 'alternativeText' is not a string.
140		:raises ValueError:     If parameter 'path' is None.
141		:raises TypeError:      If parameter 'path' is not a string.
142		:raises TypeError:      If parameter 'target' is not a string.
143		"""
144		if alternativeText is None:
145			raise ValueError("Parameter 'alternativeText' is None.")
146		elif not isinstance(alternativeText, str):
147			ex = TypeError("Parameter 'alternativeText' is not of type 'str'.")
148			ex.add_note(f"Got type '{getFullyQualifiedName(alternativeText)}'.")
149			raise ex
150
151		if path is None:
152			raise ValueError("Parameter 'path' is None.")
153		elif not isinstance(path, str):
154			ex = TypeError("Parameter 'path' is not of type 'str'.")
155			ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
156			raise ex
157
158		if target is not None and not isinstance(target, str):
159			ex = TypeError("Parameter 'target' is not of type 'str'.")
160			ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.")
161			raise ex
162
163		self._alternativeText = alternativeText
164		self._path =            path
165		self._target =          target
166		self._options =         () if options is None else tuple(options)
167
168	@readonly
169	def AlternativeText(self) -> str:
170		"""
171		Read-only property to access what the image says when it can't be shown.
172
173		:returns: The badge's alternative text.
174		"""
175		return self._alternativeText
176
177	@readonly
178	def Options(self) -> tuple[str, ...]:
179		"""
180		Read-only property to access the names of the directive's options the badge is made of.
181
182		:returns: The option names, without colons.
183		"""
184		return self._options
185
186	def ImageURL(self, settings: dict[str, str], latex: bool) -> str:
187		"""
188		Format the badge's image URL.
189
190		:param settings: The settings derived from the directive's options, which the path's placeholders name.
191		:param latex:    Whether the URL is for a LaTeX build, which needs the rasterized badge.
192		:returns:        The complete URL of the badge image.
193		"""
194		host = SHIELDS_SERVICE_PNG if latex else SHIELDS_SERVICE_SVG
195
196		return f"{host}/{self._path.format_map(settings)}"
197
198	def TargetURL(self, settings: dict[str, str]) -> Nullable[str]:
199		"""
200		Format what the badge links to.
201
202		:param settings: The settings derived from the directive's options, which the target's placeholders name.
203		:returns:        The complete target URL, or ``None`` when the badge links nowhere.
204		"""
205		if self._target is None:
206			return None
207
208		return self._target.format_map(settings)
209
210
211SHIELDS: dict[str, Shield] = {
212	"github": Shield(
213		"Sourcecode on GitHub",
214		"badge/{GitHubOrganizationBadge}-{GitHubRepositoryBadge}-63bf7f?longCache=true&style=flat-square&longCache=true"
215		"&logo=GitHub",
216		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}",
217		("github",)
218	),
219	"src-license": Shield(
220		"Code license",
221		"{SourceLicenseImage}?longCache=true&style=flat-square&label=code",
222		"{SourceLicenseURL}",
223		("source-license",)
224	),
225	"doc-license": Shield(
226		"Documentation License",
227		"badge/doc-{DocumentationLicenseBadge}-green?longCache=true&style=flat-square{DocumentationLicenseLogo}",
228		"{DocumentationLicenseURL}",
229		("documentation-license",)
230	),
231	"ghp-doc": Shield(
232		"Documentation - Read Now!",
233		"website?longCache=true&style=flat-square&label={DocumentationLabel}{DocumentationLogo}"
234		"&up_color=blueviolet&up_message=Read%20now%20%E2%9E%9A&url={DocumentationQuery}",
235		"{DocumentationURL}",
236		("documentation",)
237	),
238	"tag": Shield(
239		"GitHub tag (latest SemVer incl. pre-release)",
240		"github/v/tag/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub"
241		"&include_prereleases",
242		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/tags",
243		("github",)
244	),
245	"date": Shield(
246		"GitHub release date",
247		"github/release-date/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub",
248		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/releases",
249		("github",)
250	),
251	"github-action": Shield(
252		"GitHub Workflow - Build and Test Status",
253		"github/actions/workflow/status/{GitHubOrganization}/{GitHubRepository}/{Workflow}?{WorkflowBranch}"
254		"longCache=true&style=flat-square&label=Build%20and%20Test&logo=GitHub%20Actions&logoColor=FFFFFF",
255		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/actions/workflows/{Workflow}",
256		("github", "github-action")
257	),
258	"codacy-quality": Shield(
259		"Codacy - Quality",
260		"codacy/grade/{Codacy}?longCache=true&style=flat-square&logo=codacy",
261		"https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard",
262		("github", "codacy")
263	),
264	"codacy-coverage": Shield(
265		"Codacy - Line Coverage",
266		"codacy/coverage/{Codacy}?longCache=true&style=flat-square&logo=codacy",
267		"https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard",
268		("github", "codacy")
269	),
270	"codecov-coverage": Shield(
271		"Codecov - Branch Coverage",
272		"codecov/c/github/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=Codecov",
273		"https://codecov.io/gh/{GitHubOrganization}/{GitHubRepository}",
274		("github",)
275	),
276	"lib-status": Shield(
277		"Libraries.io status for latest release",
278		"librariesio/release/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
279		"https://libraries.io/pypi/{PyPI}",
280		("pypi",)
281	),
282	"lib-rank": Shield(
283		"Libraries.io SourceRank",
284		"librariesio/sourcerank/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
285		"https://libraries.io/pypi/{PyPI}/sourcerank",
286		("pypi",)
287	),
288	"lib-dep": Shield(
289		"Dependent repos (via libraries.io)",
290		"librariesio/dependent-repos/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
291		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/network/dependents",
292		("github", "pypi")
293	),
294	"pypi-tag": Shield(
295		"PyPI - Tag",
296		"pypi/v/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
297		"https://pypi.org/project/{PyPI}/",
298		("pypi",)
299	),
300	"pypi-status": Shield(
301		"PyPI - Status",
302		"pypi/status/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
303		options=("pypi",)
304	),
305	"pypi-python": Shield(
306		"PyPI - Python Version",
307		"pypi/pyversions/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
308		options=("pypi",)
309	),
310	"gitter": Shield(
311		"Chat on gitter",
312		"badge/chat-on%20gitter-4db797?longCache=true&style=flat-square&logo=gitter&logoColor=e8ecef",
313		"https://gitter.im/{Gitter}",
314		("gitter",)
315	),
316}
317"""Every badge the directive knows, keyed by the identifier its content names."""
318
319
320@export
321class Shields(BaseDirective):
322	"""
323	The ``shields`` directive: a project's badges, in rows.
324
325	The options state the project's coordinates, the content names the badges - one line per row, identifiers
326	separated by commas - and :data:`SHIELDS` is what the identifiers name. ``:class:`` puts additional CSS classes on
327	the rows.
328	"""
329
330	directiveName: str = "shields"  #: Name the directive is invoked by.
331
332	has_content =               True   #: A boolean; ``True`` if content is allowed.
333	required_arguments =        0      #: Number of required directive arguments.
334	optional_arguments =        0      #: Number of optional arguments after the required ones.
335	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
336	# docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
337	# spelling of this override a conflict with one of them
338	#: Mapping of option names to validator functions.
339	option_spec: dict[str, Any] = {  # type: ignore[misc]
340		"github":                strip,
341		"pypi":                  strip,
342		"codacy":                strip,
343		"gitter":                strip,
344		"source-license":        strip,
345		"documentation-license": strip,
346		"github-action":         strip,
347		"documentation":         strip,
348		"class":                 strip,
349	}
350
351	def run(self) -> list[nodes.Node]:
352		"""
353		Render the badges named in the content, once for HTML and once for LaTeX.
354
355		:returns: Two ``only`` nodes, or the message of whatever the options or the content got wrong.
356		"""
357		try:
358			rows = self._ParseRows(self.content)
359			self._CheckOptions(rows, self.options)
360			settings = self._Settings(self.options)
361		except SphinxExtensionError as ex:
362			return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
363
364		classes = ["shields"] + self.options.get("class", "").split()
365
366		return [self._Only("html", rows, settings, classes), self._Only("latex", rows, settings, classes)]
367
368	@staticmethod
369	def _ParseRows(content: Iterable[str]) -> list[list[str]]:
370		"""
371		Read the content: one row per line, identifiers separated by commas.
372
373		:param content:               The directive's content, line by line.
374		:returns:                     The identifiers, grouped into the rows they were written in.
375		:raises SphinxExtensionError: If the content is empty, or names a badge :data:`SHIELDS` doesn't know.
376		"""
377		rows = []
378		for line in content:
379			identifiers = [identifier.strip() for identifier in line.split(",") if identifier.strip() != ""]
380			if len(identifiers) > 0:
381				rows.append(identifiers)
382
383		if len(rows) == 0:
384			raise SphinxExtensionError("The directive's content names no badge.")
385
386		for row in rows:
387			for identifier in row:
388				if identifier not in SHIELDS:
389					known = ", ".join(sorted(SHIELDS))
390					raise SphinxExtensionError(f"'{identifier}' is not a known badge. Known are: {known}.")
391
392		return rows
393
394	@staticmethod
395	def _CheckOptions(rows: list[list[str]], options: dict[str, str]) -> None:
396		"""
397		Check that every badge named has the options it is made of.
398
399		:param rows:                  The identifiers, grouped into rows.
400		:param options:               The directive's options.
401		:raises SphinxExtensionError: If a badge needs an option the directive doesn't state.
402		"""
403		for row in rows:
404			for identifier in row:
405				for option in SHIELDS[identifier].Options:
406					if option not in options:
407						raise SphinxExtensionError(f"Badge '{identifier}' needs option ':{option}:'.")
408
409	@classmethod
410	def _Settings(cls, options: dict[str, str]) -> dict[str, str]:
411		"""
412		Derive the values the badge URLs are formatted from.
413
414		An option that isn't stated derives nothing; :meth:`_CheckOptions` ensures that no badge needing it is drawn.
415
416		:param options:               The directive's options.
417		:returns:                     The values, keyed by the placeholders :data:`SHIELDS` names.
418		:raises SphinxExtensionError: If an option's value is malformed, or refers to ``:github:`` without it.
419		"""
420		settings: dict[str, str] = {}
421
422		if (gitHub := options.get("github", None)) is not None:
423			if gitHub.count("/") != 1:
424				raise SphinxExtensionError(f"Option ':github:' is '{gitHub}', not '<organization>/<repository>'.")
425
426			organization, repository = gitHub.split("/")
427			settings["GitHubOrganization"] =      organization
428			settings["GitHubRepository"] =        repository
429			settings["GitHubOrganizationBadge"] = cls._EscapeBadgeText(organization)
430			settings["GitHubRepositoryBadge"] =   cls._EscapeBadgeText(repository)
431
432		if (pypi := options.get("pypi", None)) is not None:
433			settings["PyPI"] = pypi
434
435		if (codacy := options.get("codacy", None)) is not None:
436			settings["Codacy"] = codacy
437
438		if (gitter := options.get("gitter", None)) is not None:
439			settings["Gitter"] = gitter
440
441		if (sourceLicense := options.get("source-license", None)) is not None:
442			expression, link = cls._SplitLicense("source-license", sourceLicense)
443			settings["SourceLicenseURL"] = cls._ResolveLink("source-license", link, settings)
444			if expression is not None:
445				settings["SourceLicenseImage"] = f"badge/code-{cls._EscapeBadgeText(str(expression))}-blue"
446			elif "PyPI" in settings:
447				settings["SourceLicenseImage"] = f"pypi/l/{settings['PyPI']}"
448			else:
449				settings["SourceLicenseImage"] = f"github/license/{cls._GitHubSlug('source-license', settings)}"
450
451		if (documentationLicense := options.get("documentation-license", None)) is not None:
452			expression, link = cls._SplitLicense("documentation-license", documentationLicense)
453			if expression is None:
454				raise SphinxExtensionError(
455					"Option ':documentation-license:' states no license. Nothing reports a documentation's license, so "
456					"it is written before the link: '<SPDX expression> <link>'."
457				)
458
459			settings["DocumentationLicenseURL"] = cls._ResolveLink("documentation-license", link, settings)
460			settings["DocumentationLicenseBadge"] = cls._EscapeBadgeText(str(expression))
461			identifiers = [term.Identifier for term in expression.IterateExpression() if isinstance(term, BaseLicense)]
462			if all(identifier.startswith("CC") for identifier in identifiers):
463				settings["DocumentationLicenseLogo"] = "&logo=CreativeCommons&logoColor=fff"
464			else:
465				settings["DocumentationLicenseLogo"] = ""
466
467		if (gitHubAction := options.get("github-action", None)) is not None:
468			workflow, _, branch = gitHubAction.partition("@")
469			settings["Workflow"] = workflow
470			settings["WorkflowBranch"] = "" if branch == "" else f"branch={quote(branch, safe='')}&"
471
472		if (documentation := options.get("documentation", None)) is not None:
473			if documentation == "github-pages":
474				organization, repository = cls._GitHubSlug("documentation", settings).split("/")
475				url = f"https://{organization}.github.io/{repository}/"
476				logo = "&logo=GitHub&logoColor=fff"
477			elif documentation.startswith(("http://", "https://")):
478				url = documentation
479				logo = "&logo=ReadTheDocs&logoColor=fff" if ".readthedocs." in documentation else ""
480			else:
481				raise SphinxExtensionError(
482					f"Option ':documentation:' is '{documentation}', neither 'github-pages' nor an 'http(s)://' URL."
483				)
484
485			settings["DocumentationURL"] = url
486			settings["DocumentationLabel"] = quote(url.split("://", 1)[1].rstrip("/"), safe="")
487			settings["DocumentationQuery"] = quote(url, safe="")
488			settings["DocumentationLogo"] = logo
489
490		return settings
491
492	@staticmethod
493	def _SplitLicense(option: str, value: str) -> tuple[Nullable[LicenseExpression], str]:
494		"""
495		Split a license option into the license and the link, which is its last word.
496
497		:param option:                The option's name, for the message.
498		:param value:                 The option's value, ``[<SPDX expression> ]<link>``.
499		:returns:                     The parsed license expression, or ``None`` if none is stated, and the link.
500		:raises SphinxExtensionError: If the license isn't an SPDX license expression.
501		"""
502		words = value.rsplit(maxsplit=1)
503		if len(words) == 1:
504			return None, words[0]
505
506		text, link = words
507		try:
508			expression = LicenseExpression.Parse(text)
509		except LicenseExpressionError as cause:
510			ex = SphinxExtensionError(f"Option ':{option}:' states '{text}', which isn't an SPDX license expression.")
511			ex.add_note(str(cause))
512			raise ex from cause
513
514		return expression, link
515
516	@classmethod
517	def _ResolveLink(cls, option: str, link: str, settings: dict[str, str]) -> str:
518		"""
519		Resolve a link written as ``github:<path>`` or as a URL.
520
521		:param option:                The option's name, for the message.
522		:param link:                  The link as written.
523		:param settings:              The settings derived so far, which hold the repository ``:github:`` names.
524		:returns:                     The link's URL.
525		:raises SphinxExtensionError: If the link is neither form, or is ``github:`` without ``:github:``.
526		"""
527		if link.startswith("github:"):
528			return f"https://GitHub.com/{cls._GitHubSlug(option, settings)}/blob/HEAD/{link.removeprefix('github:')}"
529		elif link.startswith(("http://", "https://")):
530			return link
531
532		raise SphinxExtensionError(f"Option ':{option}:' links to '{link}', neither 'github:<path>' nor a URL.")
533
534	@staticmethod
535	def _GitHubSlug(option: str, settings: dict[str, str]) -> str:
536		"""
537		Return the repository ``:github:`` names, for an option referring to it.
538
539		:param option:                The option's name, for the message.
540		:param settings:              The settings derived so far.
541		:returns:                     ``<organization>/<repository>``.
542		:raises SphinxExtensionError: If ``:github:`` isn't stated.
543		"""
544		if "GitHubOrganization" not in settings:
545			raise SphinxExtensionError(f"Option ':{option}:' refers to the repository, which needs option ':github:'.")
546
547		return f"{settings['GitHubOrganization']}/{settings['GitHubRepository']}"
548
549	@staticmethod
550	def _EscapeBadgeText(text: str) -> str:
551		"""
552		Escape text for a static badge's path, where ``-`` and ``_`` separate the badge's fields.
553
554		:param text: The text to escape.
555		:returns:    The text with ``-`` and ``_`` doubled and everything else a URL path can't hold percent-encoded.
556		"""
557		return quote(text.replace("-", "--").replace("_", "__"), safe="")
558
559	def _Only(self, expression: str, rows: list[list[str]], settings: dict[str, str], classes: list[str]) -> only:
560		"""
561		Build one builder-specific variant of the badges.
562
563		:param expression: The ``only`` expression selecting the builder, ``html`` or ``latex``.
564		:param rows:       The identifiers, grouped into rows.
565		:param settings:   The settings derived from the directive's options, filling the URLs.
566		:param classes:    CSS classes to put on the block.
567		:returns:          An ``only`` node holding a line per row.
568		"""
569		latex = expression == "latex"
570		node = only("", expr=expression)
571		node += (block := nodes.line_block(classes=classes))
572
573		for row in rows:
574			block += (line := nodes.line())
575			for identifier in row:
576				line += self._Badge(SHIELDS[identifier], settings, latex)
577
578		return node
579
580	@staticmethod
581	def _Badge(shield: Shield, settings: dict[str, str], latex: bool) -> nodes.Node:
582		"""
583		Build one badge: an image, wrapped in a reference when it links somewhere.
584
585		:param shield:   The badge to build.
586		:param settings: The settings derived from the directive's options, filling the URLs.
587		:param latex:    Whether this is the LaTeX variant.
588		:returns:        The image, or the reference holding it.
589		"""
590		image = nodes.image(
591			"",
592			uri=shield.ImageURL(settings, latex),
593			alt=shield.AlternativeText,
594			height=str(BADGE_HEIGHT)
595		)
596
597		if (target := shield.TargetURL(settings)) is None:
598			return image
599
600		reference = nodes.reference("", refuri=target)
601		reference += image
602
603		return reference