Shields.py

Summary

Lines:

165 of 187 covered (88.2%)

Branches:

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

Coverage:

89.4%

Directory:

Sphinx

Missed lines:

354-363, 566-575, 587-600

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
98#: Host serving a badge as SVG, which is what an HTML build embeds.
99SHIELDS_SERVICE_SVG = "https://img.shields.io"
100
101#: Host serving the same badge rasterized, which is what a LaTeX build needs - a PDF cannot embed the SVG.
102SHIELDS_SERVICE_PNG = "https://raster.shields.io"
103
104#: Height every badge is scaled to, in pixels.
105BADGE_HEIGHT = 22
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' or 'path' is None.
139		:raises TypeError:      If parameter 'alternativeText', 'path' or 'target' is not a string.
140		"""
141		if alternativeText is None:
142			raise ValueError("Parameter 'alternativeText' is None.")
143		elif not isinstance(alternativeText, str):
144			ex = TypeError("Parameter 'alternativeText' is not of type 'str'.")
145			ex.add_note(f"Got type '{getFullyQualifiedName(alternativeText)}'.")
146			raise ex
147
148		if path is None:
149			raise ValueError("Parameter 'path' is None.")
150		elif not isinstance(path, str):
151			ex = TypeError("Parameter 'path' is not of type 'str'.")
152			ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
153			raise ex
154
155		if target is not None and not isinstance(target, str):
156			ex = TypeError("Parameter 'target' is not of type 'str'.")
157			ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.")
158			raise ex
159
160		self._alternativeText = alternativeText
161		self._path =            path
162		self._target =          target
163		self._options =         () if options is None else tuple(options)
164
165	@readonly
166	def AlternativeText(self) -> str:
167		"""
168		Read-only property to access what the image says when it can't be shown.
169
170		:returns: The badge's alternative text.
171		"""
172		return self._alternativeText
173
174	@readonly
175	def Options(self) -> tuple[str, ...]:
176		"""
177		Read-only property to access the names of the directive's options the badge is made of.
178
179		:returns: The option names, without colons.
180		"""
181		return self._options
182
183	def ImageURL(self, settings: dict[str, str], latex: bool) -> str:
184		"""
185		Format the badge's image URL.
186
187		:param settings: The settings derived from the directive's options, which the path's placeholders name.
188		:param latex:    Whether the URL is for a LaTeX build, which needs the rasterized badge.
189		:returns:        The complete URL of the badge image.
190		"""
191		host = SHIELDS_SERVICE_PNG if latex else SHIELDS_SERVICE_SVG
192
193		return f"{host}/{self._path.format_map(settings)}"
194
195	def TargetURL(self, settings: dict[str, str]) -> Nullable[str]:
196		"""
197		Format what the badge links to.
198
199		:param settings: The settings derived from the directive's options, which the target's placeholders name.
200		:returns:        The complete target URL, or ``None`` when the badge links nowhere.
201		"""
202		if self._target is None:
203			return None
204
205		return self._target.format_map(settings)
206
207
208#: Every badge the directive knows, keyed by the identifier its content names.
209SHIELDS: dict[str, Shield] = {
210	"github": Shield(
211		"Sourcecode on GitHub",
212		"badge/{GitHubOrganizationBadge}-{GitHubRepositoryBadge}-63bf7f?longCache=true&style=flat-square&longCache=true"
213		"&logo=GitHub",
214		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}",
215		("github",)
216	),
217	"src-license": Shield(
218		"Code license",
219		"{SourceLicenseImage}?longCache=true&style=flat-square&label=code",
220		"{SourceLicenseURL}",
221		("source-license",)
222	),
223	"doc-license": Shield(
224		"Documentation License",
225		"badge/doc-{DocumentationLicenseBadge}-green?longCache=true&style=flat-square{DocumentationLicenseLogo}",
226		"{DocumentationLicenseURL}",
227		("documentation-license",)
228	),
229	"ghp-doc": Shield(
230		"Documentation - Read Now!",
231		"website?longCache=true&style=flat-square&label={DocumentationLabel}{DocumentationLogo}"
232		"&up_color=blueviolet&up_message=Read%20now%20%E2%9E%9A&url={DocumentationQuery}",
233		"{DocumentationURL}",
234		("documentation",)
235	),
236	"tag": Shield(
237		"GitHub tag (latest SemVer incl. pre-release)",
238		"github/v/tag/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub"
239		"&include_prereleases",
240		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/tags",
241		("github",)
242	),
243	"date": Shield(
244		"GitHub release date",
245		"github/release-date/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=GitHub",
246		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/releases",
247		("github",)
248	),
249	"github-action": Shield(
250		"GitHub Workflow - Build and Test Status",
251		"github/actions/workflow/status/{GitHubOrganization}/{GitHubRepository}/{Workflow}?{WorkflowBranch}"
252		"longCache=true&style=flat-square&label=Build%20and%20Test&logo=GitHub%20Actions&logoColor=FFFFFF",
253		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/actions/workflows/{Workflow}",
254		("github", "github-action")
255	),
256	"codacy-quality": Shield(
257		"Codacy - Quality",
258		"codacy/grade/{Codacy}?longCache=true&style=flat-square&logo=codacy",
259		"https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard",
260		("github", "codacy")
261	),
262	"codacy-coverage": Shield(
263		"Codacy - Line Coverage",
264		"codacy/coverage/{Codacy}?longCache=true&style=flat-square&logo=codacy",
265		"https://app.codacy.com/gh/{GitHubOrganization}/{GitHubRepository}/dashboard",
266		("github", "codacy")
267	),
268	"codecov-coverage": Shield(
269		"Codecov - Branch Coverage",
270		"codecov/c/github/{GitHubOrganization}/{GitHubRepository}?longCache=true&style=flat-square&logo=Codecov",
271		"https://codecov.io/gh/{GitHubOrganization}/{GitHubRepository}",
272		("github",)
273	),
274	"lib-status": Shield(
275		"Libraries.io status for latest release",
276		"librariesio/release/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
277		"https://libraries.io/pypi/{PyPI}",
278		("pypi",)
279	),
280	"lib-rank": Shield(
281		"Libraries.io SourceRank",
282		"librariesio/sourcerank/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
283		"https://libraries.io/pypi/{PyPI}/sourcerank",
284		("pypi",)
285	),
286	"lib-dep": Shield(
287		"Dependent repos (via libraries.io)",
288		"librariesio/dependent-repos/pypi/{PyPI}?longCache=true&style=flat-square&logo=Libraries.io&logoColor=fff",
289		"https://GitHub.com/{GitHubOrganization}/{GitHubRepository}/network/dependents",
290		("github", "pypi")
291	),
292	"pypi-tag": Shield(
293		"PyPI - Tag",
294		"pypi/v/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
295		"https://pypi.org/project/{PyPI}/",
296		("pypi",)
297	),
298	"pypi-status": Shield(
299		"PyPI - Status",
300		"pypi/status/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
301		options=("pypi",)
302	),
303	"pypi-python": Shield(
304		"PyPI - Python Version",
305		"pypi/pyversions/{PyPI}?longCache=true&style=flat-square&logo=PyPI&logoColor=FBE072",
306		options=("pypi",)
307	),
308	"gitter": Shield(
309		"Chat on gitter",
310		"badge/chat-on%20gitter-4db797?longCache=true&style=flat-square&logo=gitter&logoColor=e8ecef",
311		"https://gitter.im/{Gitter}",
312		("gitter",)
313	),
314}
315
316
317@export
318class Shields(BaseDirective):
319	"""
320	The ``shields`` directive: a project's badges, in rows.
321
322	The options state the project's coordinates, the content names the badges - one line per row, identifiers
323	separated by commas - and :data:`SHIELDS` is what the identifiers name. ``:class:`` puts additional CSS classes on
324	the rows.
325	"""
326
327	directiveName: str = "shields"  #: Name the directive is invoked by.
328
329	has_content =               True   #: A boolean; ``True`` if content is allowed.
330	required_arguments =        0      #: Number of required directive arguments.
331	optional_arguments =        0      #: Number of optional arguments after the required ones.
332	final_argument_whitespace = False  #: A boolean; ``True`` if the last argument may contain spaces.
333	# docutils declares 'option_spec' on 'Directive' and 'BaseDirective' assigns it, so mypy calls every
334	# spelling of this override a conflict with one of them
335	#: Mapping of option names to validator functions.
336	option_spec: dict[str, Any] = {  # type: ignore[misc]
337		"github":                strip,
338		"pypi":                  strip,
339		"codacy":                strip,
340		"gitter":                strip,
341		"source-license":        strip,
342		"documentation-license": strip,
343		"github-action":         strip,
344		"documentation":         strip,
345		"class":                 strip,
346	}
347
348	def run(self) -> list[nodes.Node]:
349		"""
350		Render the badges named in the content, once for HTML and once for LaTeX.
351
352		:returns: Two ``only`` nodes, or the message of whatever the options or the content got wrong.
353		"""
354		try:
355			rows = self._ParseRows(self.content)
356			self._CheckOptions(rows, self.options)
357			settings = self._Settings(self.options)
358		except SphinxExtensionError as ex:
359			return [self.state.document.reporter.error(f"{self.directiveName}: {ex}", line=self.lineno)]
360
361		classes = ["shields"] + self.options.get("class", "").split()
362
363		return [self._Only("html", rows, settings, classes), self._Only("latex", rows, settings, classes)]
364
365	@staticmethod
366	def _ParseRows(content: Iterable[str]) -> list[list[str]]:
367		"""
368		Read the content: one row per line, identifiers separated by commas.
369
370		:param content:               The directive's content, line by line.
371		:returns:                     The identifiers, grouped into the rows they were written in.
372		:raises SphinxExtensionError: If the content is empty, or names a badge :data:`SHIELDS` doesn't know.
373		"""
374		rows = []
375		for line in content:
376			identifiers = [identifier.strip() for identifier in line.split(",") if identifier.strip() != ""]
377			if len(identifiers) > 0:
378				rows.append(identifiers)
379
380		if len(rows) == 0:
381			raise SphinxExtensionError("The directive's content names no badge.")
382
383		for row in rows:
384			for identifier in row:
385				if identifier not in SHIELDS:
386					known = ", ".join(sorted(SHIELDS))
387					raise SphinxExtensionError(f"'{identifier}' is not a known badge. Known are: {known}.")
388
389		return rows
390
391	@staticmethod
392	def _CheckOptions(rows: list[list[str]], options: dict[str, str]) -> None:
393		"""
394		Check that every badge named has the options it is made of.
395
396		:param rows:                  The identifiers, grouped into rows.
397		:param options:               The directive's options.
398		:raises SphinxExtensionError: If a badge needs an option the directive doesn't state.
399		"""
400		for row in rows:
401			for identifier in row:
402				for option in SHIELDS[identifier].Options:
403					if option not in options:
404						raise SphinxExtensionError(f"Badge '{identifier}' needs option ':{option}:'.")
405
406	@classmethod
407	def _Settings(cls, options: dict[str, str]) -> dict[str, str]:
408		"""
409		Derive the values the badge URLs are formatted from.
410
411		An option that isn't stated derives nothing; :meth:`_CheckOptions` ensures that no badge needing it is drawn.
412
413		:param options:               The directive's options.
414		:returns:                     The values, keyed by the placeholders :data:`SHIELDS` names.
415		:raises SphinxExtensionError: If an option's value is malformed, or refers to ``:github:`` without it.
416		"""
417		settings: dict[str, str] = {}
418
419		if (gitHub := options.get("github", None)) is not None:
420			if gitHub.count("/") != 1:
421				raise SphinxExtensionError(f"Option ':github:' is '{gitHub}', not '<organization>/<repository>'.")
422
423			organization, repository = gitHub.split("/")
424			settings["GitHubOrganization"] =      organization
425			settings["GitHubRepository"] =        repository
426			settings["GitHubOrganizationBadge"] = cls._EscapeBadgeText(organization)
427			settings["GitHubRepositoryBadge"] =   cls._EscapeBadgeText(repository)
428
429		if (pypi := options.get("pypi", None)) is not None:
430			settings["PyPI"] = pypi
431
432		if (codacy := options.get("codacy", None)) is not None:
433			settings["Codacy"] = codacy
434
435		if (gitter := options.get("gitter", None)) is not None:
436			settings["Gitter"] = gitter
437
438		if (sourceLicense := options.get("source-license", None)) is not None:
439			expression, link = cls._SplitLicense("source-license", sourceLicense)
440			settings["SourceLicenseURL"] = cls._ResolveLink("source-license", link, settings)
441			if expression is not None:
442				settings["SourceLicenseImage"] = f"badge/code-{cls._EscapeBadgeText(str(expression))}-blue"
443			elif "PyPI" in settings:
444				settings["SourceLicenseImage"] = f"pypi/l/{settings['PyPI']}"
445			else:
446				settings["SourceLicenseImage"] = f"github/license/{cls._GitHubSlug('source-license', settings)}"
447
448		if (documentationLicense := options.get("documentation-license", None)) is not None:
449			expression, link = cls._SplitLicense("documentation-license", documentationLicense)
450			if expression is None:
451				raise SphinxExtensionError(
452					"Option ':documentation-license:' states no license. Nothing reports a documentation's license, so "
453					"it is written before the link: '<SPDX expression> <link>'."
454				)
455
456			settings["DocumentationLicenseURL"] = cls._ResolveLink("documentation-license", link, settings)
457			settings["DocumentationLicenseBadge"] = cls._EscapeBadgeText(str(expression))
458			identifiers = [term.Identifier for term in expression.IterateExpression() if isinstance(term, BaseLicense)]
459			if all(identifier.startswith("CC") for identifier in identifiers):
460				settings["DocumentationLicenseLogo"] = "&logo=CreativeCommons&logoColor=fff"
461			else:
462				settings["DocumentationLicenseLogo"] = ""
463
464		if (gitHubAction := options.get("github-action", None)) is not None:
465			workflow, _, branch = gitHubAction.partition("@")
466			settings["Workflow"] = workflow
467			settings["WorkflowBranch"] = "" if branch == "" else f"branch={quote(branch, safe='')}&"
468
469		if (documentation := options.get("documentation", None)) is not None:
470			if documentation == "github-pages":
471				organization, repository = cls._GitHubSlug("documentation", settings).split("/")
472				url = f"https://{organization}.github.io/{repository}/"
473				logo = "&logo=GitHub&logoColor=fff"
474			elif documentation.startswith(("http://", "https://")):
475				url = documentation
476				logo = "&logo=ReadTheDocs&logoColor=fff" if ".readthedocs." in documentation else ""
477			else:
478				raise SphinxExtensionError(
479					f"Option ':documentation:' is '{documentation}', neither 'github-pages' nor an 'http(s)://' URL."
480				)
481
482			settings["DocumentationURL"] = url
483			settings["DocumentationLabel"] = quote(url.split("://", 1)[1].rstrip("/"), safe="")
484			settings["DocumentationQuery"] = quote(url, safe="")
485			settings["DocumentationLogo"] = logo
486
487		return settings
488
489	@staticmethod
490	def _SplitLicense(option: str, value: str) -> tuple[Nullable[LicenseExpression], str]:
491		"""
492		Split a license option into the license and the link, which is its last word.
493
494		:param option:                The option's name, for the message.
495		:param value:                 The option's value, ``[<SPDX expression> ]<link>``.
496		:returns:                     The parsed license expression, or ``None`` if none is stated, and the link.
497		:raises SphinxExtensionError: If the license isn't an SPDX license expression.
498		"""
499		words = value.rsplit(maxsplit=1)
500		if len(words) == 1:
501			return None, words[0]
502
503		text, link = words
504		try:
505			expression = LicenseExpression.Parse(text)
506		except LicenseExpressionError as cause:
507			ex = SphinxExtensionError(f"Option ':{option}:' states '{text}', which isn't an SPDX license expression.")
508			ex.add_note(str(cause))
509			raise ex from cause
510
511		return expression, link
512
513	@classmethod
514	def _ResolveLink(cls, option: str, link: str, settings: dict[str, str]) -> str:
515		"""
516		Resolve a link written as ``github:<path>`` or as a URL.
517
518		:param option:                The option's name, for the message.
519		:param link:                  The link as written.
520		:param settings:              The settings derived so far, which hold the repository ``:github:`` names.
521		:returns:                     The link's URL.
522		:raises SphinxExtensionError: If the link is neither form, or is ``github:`` without ``:github:``.
523		"""
524		if link.startswith("github:"):
525			return f"https://GitHub.com/{cls._GitHubSlug(option, settings)}/blob/HEAD/{link.removeprefix('github:')}"
526		elif link.startswith(("http://", "https://")):
527			return link
528
529		raise SphinxExtensionError(f"Option ':{option}:' links to '{link}', neither 'github:<path>' nor a URL.")
530
531	@staticmethod
532	def _GitHubSlug(option: str, settings: dict[str, str]) -> str:
533		"""
534		Return the repository ``:github:`` names, for an option referring to it.
535
536		:param option:                The option's name, for the message.
537		:param settings:              The settings derived so far.
538		:returns:                     ``<organization>/<repository>``.
539		:raises SphinxExtensionError: If ``:github:`` isn't stated.
540		"""
541		if "GitHubOrganization" not in settings:
542			raise SphinxExtensionError(f"Option ':{option}:' refers to the repository, which needs option ':github:'.")
543
544		return f"{settings['GitHubOrganization']}/{settings['GitHubRepository']}"
545
546	@staticmethod
547	def _EscapeBadgeText(text: str) -> str:
548		"""
549		Escape text for a static badge's path, where ``-`` and ``_`` separate the badge's fields.
550
551		:param text: The text to escape.
552		:returns:    The text with ``-`` and ``_`` doubled and everything else a URL path can't hold percent-encoded.
553		"""
554		return quote(text.replace("-", "--").replace("_", "__"), safe="")
555
556	def _Only(self, expression: str, rows: list[list[str]], settings: dict[str, str], classes: list[str]) -> only:
557		"""
558		Build one builder-specific variant of the badges.
559
560		:param expression: The ``only`` expression selecting the builder, ``html`` or ``latex``.
561		:param rows:       The identifiers, grouped into rows.
562		:param settings:   The settings derived from the directive's options, filling the URLs.
563		:param classes:    CSS classes to put on the block.
564		:returns:          An ``only`` node holding a line per row.
565		"""
566		latex = expression == "latex"
567		node = only("", expr=expression)
568		node += (block := nodes.line_block(classes=classes))
569
570		for row in rows:
571			block += (line := nodes.line())
572			for identifier in row:
573				line += self._Badge(SHIELDS[identifier], settings, latex)
574
575		return node
576
577	@staticmethod
578	def _Badge(shield: Shield, settings: dict[str, str], latex: bool) -> nodes.Node:
579		"""
580		Build one badge: an image, wrapped in a reference when it links somewhere.
581
582		:param shield:   The badge to build.
583		:param settings: The settings derived from the directive's options, filling the URLs.
584		:param latex:    Whether this is the LaTeX variant.
585		:returns:        The image, or the reference holding it.
586		"""
587		image = nodes.image(
588			"",
589			uri=shield.ImageURL(settings, latex),
590			alt=shield.AlternativeText,
591			height=str(BADGE_HEIGHT)
592		)
593
594		if (target := shield.TargetURL(settings)) is None:
595			return image
596
597		reference = nodes.reference("", refuri=target)
598		reference += image
599
600		return reference