Skip to content

Commit df473c7

Browse files
committed
Cross-link struct/enum mentions to their definitions, with hover-peek popover
1 parent 1979bdb commit df473c7

5 files changed

Lines changed: 207 additions & 0 deletions

File tree

docs/javascripts/peek.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/* Hover "peek" popover for cross-reference links (a.xref[data-peek]).
2+
Uses event delegation on document so it survives Material's instant navigation. */
3+
(function () {
4+
let pop = null;
5+
6+
function popover() {
7+
if (!pop) {
8+
pop = document.createElement("div");
9+
pop.className = "api-peek";
10+
pop.setAttribute("role", "tooltip");
11+
pop.style.display = "none";
12+
document.body.appendChild(pop);
13+
}
14+
return pop;
15+
}
16+
17+
function show(link) {
18+
const text = link.getAttribute("data-peek");
19+
if (!text) return;
20+
const el = popover();
21+
el.textContent = text;
22+
el.style.display = "block";
23+
24+
const r = link.getBoundingClientRect();
25+
el.style.top = window.scrollY + r.bottom + 6 + "px";
26+
el.style.left = window.scrollX + r.left + "px";
27+
28+
// Nudge back inside the viewport if it overflows on the right.
29+
const pr = el.getBoundingClientRect();
30+
if (pr.right > window.innerWidth - 8) {
31+
el.style.left =
32+
Math.max(8, window.scrollX + window.innerWidth - pr.width - 8) + "px";
33+
}
34+
}
35+
36+
function hide() {
37+
if (pop) pop.style.display = "none";
38+
}
39+
40+
document.addEventListener("mouseover", function (e) {
41+
const link = e.target.closest && e.target.closest("a.xref");
42+
if (link) show(link);
43+
});
44+
document.addEventListener("mouseout", function (e) {
45+
const link = e.target.closest && e.target.closest("a.xref");
46+
if (link) hide();
47+
});
48+
document.addEventListener("click", hide, true);
49+
})();

docs/stylesheets/extra.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,30 @@
99
white-space: normal;
1010
overflow-wrap: anywhere;
1111
}
12+
13+
/* Cross-reference links to type definitions (injected by hooks/crosslink.py). */
14+
.md-typeset a.xref {
15+
color: var(--md-accent-fg-color);
16+
text-decoration: underline dotted;
17+
text-underline-offset: 2px;
18+
cursor: help;
19+
}
20+
.md-typeset a.xref:hover {
21+
text-decoration: underline solid;
22+
}
23+
24+
/* Hover "peek" popover (peek.js). */
25+
.api-peek {
26+
position: absolute;
27+
z-index: 100;
28+
max-width: 28rem;
29+
padding: 0.5rem 0.7rem;
30+
border: 1px solid var(--md-default-fg-color--lighter);
31+
border-radius: 0.2rem;
32+
background: var(--md-default-bg-color);
33+
color: var(--md-default-fg-color);
34+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
35+
font-size: 0.7rem;
36+
line-height: 1.5;
37+
pointer-events: none;
38+
}

hooks/crosslink.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""MkDocs post-build hook: cross-link struct/enum/constants mentions to their definitions.
2+
3+
Markdown can't place a link inside an inline code span, so this runs after the build
4+
and edits the rendered HTML directly:
5+
6+
1. Pass 1 — scan every page's <article> for definition entries (a `<code>` whose
7+
following text starts with "(struct)", "(enum)" or "(constants)"), tag each with an
8+
anchor id, and record symbol -> (page, anchor, definition text).
9+
2. Pass 2 — in every inline <code>, wrap whole-word mentions of those known symbols in
10+
<a class="xref"> links to the definition, carrying the definition text in data-peek
11+
for the hover popover (peek.js).
12+
13+
Only symbols actually defined in the doc are linked, so primitives (number, bool,
14+
cstring, table, ...) and externally-defined types are never touched. Code blocks
15+
(```...```) and a symbol's own definition element are skipped.
16+
"""
17+
18+
import glob
19+
import os
20+
import re
21+
22+
from bs4 import BeautifulSoup
23+
24+
DEF_RE = re.compile(r"^\s*\((struct|enum|constants)\b")
25+
IDENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
26+
ARTICLE_RE = re.compile(r"(<article\b[^>]*>)(.*?)(</article>)", re.S)
27+
PEEK_MAX = 320
28+
29+
30+
def _definition_marker(code):
31+
"""If this <code> introduces a type, return the text node carrying its '(kind)'."""
32+
host = code.parent if (code.parent and code.parent.name == "strong") else code
33+
nxt = host.next_sibling
34+
if isinstance(nxt, str):
35+
return nxt
36+
return nxt.get_text() if hasattr(nxt, "get_text") and nxt is not None else ""
37+
38+
39+
def on_post_build(config, **kwargs):
40+
site_dir = config["site_dir"]
41+
pages = glob.glob(os.path.join(site_dir, "**", "*.html"), recursive=True)
42+
43+
docs = [] # {path, text, match, soup, dirty}
44+
registry = {} # symbol -> (abs_path, anchor, peek_text)
45+
46+
# ---- Pass 1: collect definitions ----
47+
for path in pages:
48+
with open(path, encoding="utf-8") as fh:
49+
text = fh.read()
50+
m = ARTICLE_RE.search(text)
51+
if not m:
52+
continue
53+
soup = BeautifulSoup(m.group(2), "html.parser")
54+
doc = {"path": path, "text": text, "match": m, "soup": soup, "dirty": False}
55+
docs.append(doc)
56+
57+
for code in soup.find_all("code"):
58+
if code.find_parent("pre"):
59+
continue
60+
name = code.get_text()
61+
if not IDENT_RE.match(name) or name in registry:
62+
continue
63+
if DEF_RE.match(_definition_marker(code)):
64+
anchor = "ref-" + name
65+
code["id"] = anchor
66+
doc["dirty"] = True
67+
container = code.find_parent(["li", "p"]) or code
68+
peek = " ".join(container.get_text().split())
69+
if len(peek) > PEEK_MAX:
70+
peek = peek[: PEEK_MAX - 1] + "…"
71+
registry[name] = (path, anchor, peek)
72+
73+
if not registry:
74+
return
75+
76+
symbols = sorted(registry, key=len, reverse=True)
77+
mention_re = re.compile(
78+
r"(?<![A-Za-z0-9_])(" + "|".join(map(re.escape, symbols)) + r")(?![A-Za-z0-9_])"
79+
)
80+
81+
# ---- Pass 2: link mentions ----
82+
for doc in docs:
83+
soup = doc["soup"]
84+
for code in soup.find_all("code"):
85+
if code.find_parent("pre"):
86+
continue
87+
if str(code.get("id", "")).startswith("ref-"):
88+
continue # don't self-link a definition's own name
89+
for text_node in list(code.find_all(string=True, recursive=False)):
90+
s = str(text_node)
91+
if not mention_re.search(s):
92+
continue
93+
parts, last = [], 0
94+
for mt in mention_re.finditer(s):
95+
sym = mt.group(1)
96+
if mt.start() > last:
97+
parts.append(soup.new_string(s[last:mt.start()]))
98+
target_path, anchor, peek = registry[sym]
99+
rel = os.path.relpath(target_path, os.path.dirname(doc["path"]))
100+
rel = rel.replace(os.sep, "/")
101+
link = soup.new_tag("a", href=f"{rel}#{anchor}")
102+
link["class"] = "xref"
103+
link["data-peek"] = peek
104+
link.string = sym
105+
parts.append(link)
106+
last = mt.end()
107+
if last < len(s):
108+
parts.append(soup.new_string(s[last:]))
109+
text_node.replace_with(*parts)
110+
doc["dirty"] = True
111+
112+
# ---- Write back only the <article> body of changed pages ----
113+
linked = 0
114+
for doc in docs:
115+
if not doc["dirty"]:
116+
continue
117+
m = doc["match"]
118+
new_inner = str(doc["soup"])
119+
new_text = doc["text"][: m.start(2)] + new_inner + doc["text"][m.end(2):]
120+
with open(doc["path"], "w", encoding="utf-8") as fh:
121+
fh.write(new_text)
122+
linked += 1
123+
124+
print(f"[crosslink] {len(registry)} definitions, links injected across {linked} page(s)")

mkdocs.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ markdown_extensions:
4848
extra_css:
4949
- stylesheets/extra.css
5050

51+
extra_javascript:
52+
- javascripts/peek.js
53+
54+
hooks:
55+
- hooks/crosslink.py
56+
5157
nav:
5258
- Home: index.md
5359
- 12.1.0 (PTR):

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
mkdocs-material>=9.5,<10
2+
beautifulsoup4>=4.12

0 commit comments

Comments
 (0)