Coverage for /builds/svesch/cikit/src/cikit/sphinx_ext/hash.py: 100%
28 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-09 14:17 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-09 14:17 +0000
1# SPDX-License-Identifier: GPL-3.0-or-later
2"""Sphinx role for compact and copyable SHA-256 hashes.
4The ``cikit-hash`` role renders a shortened SHA-256 value in HTML while keeping
5the complete digest available as a tooltip. Clicking the rendered value copies
6the full digest to the clipboard; non-HTML builders receive the complete hash.
8Author:
9 Sven Schlender
10Date:
11 2026-09-25
12"""
14from html import escape
15import re
17from docutils import nodes
18from sphinx import addnodes
19from sphinx.errors import SphinxError
20from sphinx.util.docutils import SphinxRole
23HASH_PREVIEW_LENGTH = 12
24SHA256_PATTERN = re.compile(r"^[0-9a-fA-F]{64}$")
26COPY_SCRIPT = """
27(() => {
28 const selector = ".cikit-hash[data-hash]";
30 const copyHash = (element) => {
31 const value = element.dataset.hash;
32 if (!value || !navigator.clipboard || !navigator.clipboard.writeText) {
33 return;
34 }
35 navigator.clipboard.writeText(value);
36 };
38 document.addEventListener("click", (event) => {
39 const target = event.target.closest?.(selector);
40 if (target) {
41 copyHash(target);
42 }
43 });
45 document.addEventListener("keydown", (event) => {
46 const target = event.target.closest?.(selector);
47 if (target && (event.key === "Enter" || event.key === " ")) {
48 event.preventDefault();
49 copyHash(target);
50 }
51 });
52})();
53""".strip()
56class CikitHashRole(SphinxRole):
57 """Render a SHA-256 digest as a compact interactive inline value.
59 HTML output contains a shortened digest with the complete value stored in
60 the tooltip and a data attribute used by the clipboard helper. Non-HTML
61 output receives the complete digest so no information is lost.
62 """
64 def run(self):
65 """Validate and render the SHA-256 value supplied to the role.
67 The digest is normalized to lowercase before rendering. Invalid values
68 fail the Sphinx build instead of silently producing a misleading hash
69 representation in generated reports.
71 Returns:
72 Tuple containing builder-specific nodes and system messages.
74 Raises:
75 SphinxError: If the role content is not a complete SHA-256 digest.
76 """
77 value = self.text.strip()
78 if not SHA256_PATTERN.fullmatch(value):
79 raise SphinxError("Invalid cikit hash, expected a 64-character SHA-256 digest.")
81 value = value.lower()
82 preview = f"{value[:HASH_PREVIEW_LENGTH]}…"
83 safe_value = escape(value, quote=True)
84 safe_preview = escape(preview)
86 html = (f'<code class="cikit-hash" data-hash="{safe_value}" '
87 f'title="SHA-256: {safe_value}" role="button" tabindex="0" '
88 f'style="cursor: copy;">{safe_preview}</code>')
90 html_node = addnodes.only(expr="html")
91 html_node += nodes.raw("", html, format="html")
93 fallback_node = addnodes.only(expr="not html")
94 fallback_node += nodes.literal(self.rawtext, value)
96 return [html_node, fallback_node], []
99def setup(app):
100 """Register the cikit SHA-256 role and clipboard helper with Sphinx.
102 The role is available as ``cikit-hash``. Clipboard support is injected as
103 inline JavaScript through Sphinx, avoiding additional static package files
104 and keeping the extension self-contained.
106 Args:
107 app: Active Sphinx application.
109 Returns:
110 Sphinx extension metadata.
111 """
112 app.add_role("cikit-hash", CikitHashRole())
113 app.add_js_file(None, body=COPY_SCRIPT)
115 return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True}