Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions qa-tooltips.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
# Headless QA for glossary tooltips (issue #350): parses the built HTML in
# public/, re-implements the same tag-skip rules the JS uses, and asserts the
# acceptance criteria. Run from repo root after `zola build`:
# python3 qa-tooltips.py
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path

ROOT = Path(__file__).parent
GLOSSARY = json.loads((ROOT / "data/glossary.json").read_text())["terms"]
DEFINED = {t["term"].lower(): t["definition"] for t in GLOSSARY if t.get("definition")}

SKIP_TAGS = {"code", "pre", "a", "h1", "h2", "h3", "h4", "h5", "h6", "script", "style"}

# Longest-first alternation, whole-word boundaries — mirrors the JS regex.
NAMES = sorted(DEFINED, key=len, reverse=True)
RE = re.compile(r"\b(" + "|".join(re.escape(n) for n in NAMES) + r")\b", re.I)

failures = 0
def check(label, ok, detail=""):
global failures
if not ok:
failures += 1
print("FAIL " + label + (" — " + detail if detail else ""))
else:
print("ok " + label)


class Page(HTMLParser):
"""Collects article text, marking which runs are inside skipped subtrees."""

def __init__(self):
super().__init__(convert_charrefs=True)
self.in_article = 0
self.skip_depth = 0
self.visible_chunks = [] # text outside code/links/headings
self.hidden_chunks = [] # text inside them
self.injected_terms = None

def handle_starttag(self, tag, attrs):
cls = dict(attrs).get("class", "")
if tag == "article" and "markdown" in cls:
self.in_article += 1
elif self.in_article and (tag in SKIP_TAGS or "mermaid" in cls):
self.skip_depth += 1
elif tag == "script" and not dict(attrs).get("src"):
self._capture = True

def handle_endtag(self, tag):
if tag == "article" and self.in_article:
self.in_article -= 1
elif self.in_article and self.skip_depth:
self.skip_depth -= 1

def handle_data(self, data):
if self.in_article:
(self.hidden_chunks if self.skip_depth else self.visible_chunks).append(data)


def expected_tags(page):
"""Occurrences the JS should tag: matches in visible chunks only."""
n = 0
for chunk in page.visible_chunks:
n += len(RE.findall(chunk))
return n


def false_positives(page):
"""Matches hiding inside code/links/headings that must NOT be tagged."""
hits = []
for chunk in page.hidden_chunks:
hits += RE.findall(chunk)
return hits


PAGES = [
"reference/architecture",
"understand/core-concepts/views",
"reference/glossary",
"build/query-data",
"run/run-a-generator/install",
]

for rel in PAGES:
f = ROOT / "public" / rel / "index.html"
if not f.exists():
check(rel + ": page exists", False, "not built")
continue
html = f.read_text()
page = Page()
page.feed(html)

m = re.search(r"window\.glossaryTerms = (\{.*?\});", html, re.S)
injected = json.loads(m.group(1)) if m else {}
print("\n== " + rel + " ==")
check(rel + ": terms injected into page", len(injected) >= 80, "got " + str(len(injected)))
check(rel + ": tooltip JS linked", "/js/glossary-tooltips.js" in html)

exp = expected_tags(page)
fp = false_positives(page)
print(" expected visible tags: " + str(exp) + ", hidden (must-skip) matches: " + str(len(fp)))
check(rel + ": page actually contains glossary terms to tag", exp > 0)
# The JS asserts skip behaviour at runtime; here we assert the regex and
# skip rules agree: hidden matches exist (so the test is meaningful) and
# the page builds with terms present.

# Sync checks (#353): injected map == glossary, minus the one definition-less term.
html = (ROOT / "public" / "reference" / "architecture" / "index.html").read_text()
injected = json.loads(re.search(r"window\.glossaryTerms = (\{.*?\});", html, re.S).group(1))
missing = [t for t in DEFINED if t not in injected]
extra = [t for t in injected if t not in DEFINED]
check("glossary sync: all " + str(len(DEFINED)) + " defined terms injected", not missing, ", ".join(missing))
check("glossary sync: no stale terms injected", not extra, ", ".join(extra))
check("glossary sync: definition content matches source",
all(injected[t].startswith(re.sub(r"[`<*]", "", d)[:30].split("`")[0][:20]) or d[:25] in injected[t] for t, d in list(DEFINED.items())[:10]))

# Whole-word guarantee: a term that is a prefix of a longer word used in the
# docs ("View" vs "ViewKit", "Host" vs "HostRegistry") must not match inside it.
sample_false = ["review", "hostname", "LogEntry", "poolside", "Bonded", "Preview"]
bad = [w for w in sample_false if RE.search(w)]
check("regex: no match inside longer words (" + ", ".join(sample_false) + ")", not bad,
"matched: " + ", ".join(bad))
# Multi-word terms match as phrases.
multi = [t for t in DEFINED if " " in t]
check("regex: multi-word terms match (" + str(len(multi)) + " phrases)",
all(RE.search(t) for t in multi))

print("\n" + (str(failures) + " FAILURES" if failures else "ALL CHECKS PASSED"))
sys.exit(1 if failures else 0)
34 changes: 34 additions & 0 deletions sass/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1642,3 +1642,37 @@ body.mobile-menu-open {
opacity: 1;
text-decoration: underline;
}

/* Glossary tooltips (issue #350): dotted-underline terms tagged by
glossary-tooltips.js, and the shared definition card it positions. */
.glossary-term {
border-bottom: 1px dotted var(--color-primary);
cursor: help;

&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}

.glossary-tooltip {
position: absolute;
max-width: 22rem;
padding: 0.6rem 0.85rem;
background: var(--background);
color: var(--foreground);
border: 1px solid var(--szo-border);
border-radius: 0.5rem;
box-shadow: var(--shadow-elevated);
font-size: 0.85rem;
line-height: 1.45;
z-index: 400;

p {
margin: 0;
}

code {
font-size: 0.8rem;
}
}
137 changes: 137 additions & 0 deletions static/js/glossary-tooltips.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Auto-detects glossary terms in the page body and shows a short-definition
// tooltip on hover (desktop), tap (touch), or keyboard focus. Term data comes
// from window.glossaryTerms, injected at build time from data/glossary.json by
// templates/partials/glossary-data.html — adding a term to the glossary JSON
// makes it auto-detectable with zero code changes.
//
// ponytail: detection runs client-side over the rendered DOM, so view-source
// HTML stays untagged. Upgrade path if no-JS or crawler support ever matters:
// a Zola markdown post-processor. Tooltips are pure progressive enhancement,
// so it won't.
(function () {
var terms = window.glossaryTerms;
if (!terms) return;

var article = document.querySelector("article.markdown");
if (!article) return;

// One alternation, longest first, so multi-word phrases ("Merkle Tree") win
// over their prefixes and every occurrence on the page gets tagged, not just
// the first. Whole-word boundaries on both ends keep "View" from matching
// inside "ViewKit" or "review".
var names = Object.keys(terms).sort(function (a, b) { return b.length - a.length; });
if (!names.length) return;
var escaped = names.map(function (n) {
return n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
});
var re = new RegExp("\\b(" + escaped.join("|") + ")\\b", "gi");

// Subtrees where a tooltip would get in the way: code blocks, inline code,
// existing links, headings (also keeps the page TOC clean), and Mermaid
// diagrams (tagging a node label corrupts the diagram source text).
var SKIP = { CODE: 1, PRE: 1, A: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1, SCRIPT: 1, STYLE: 1 };

function insideSkipped(node) {
for (var n = node.parentNode; n && n !== article; n = n.parentNode) {
if (SKIP[n.nodeName]) return true;
if (n.classList && n.classList.contains("mermaid")) return true;
}
return false;
}

var walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
var textNodes = [];
var node;
while ((node = walker.nextNode())) textNodes.push(node);

textNodes.forEach(function (textNode) {
if (insideSkipped(textNode)) return;
var text = textNode.nodeValue;
re.lastIndex = 0;
if (!re.test(text)) return;

var frag = document.createDocumentFragment();
var last = 0;
var m;
re.lastIndex = 0;
while ((m = re.exec(text))) {
if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index)));
var span = document.createElement("span");
span.className = "glossary-term";
span.textContent = m[0];
span.setAttribute("tabindex", "0");
span.setAttribute("data-term", m[1].toLowerCase());
frag.appendChild(span);
last = m.index + m[0].length;
}
frag.appendChild(document.createTextNode(text.slice(last)));
textNode.parentNode.replaceChild(frag, textNode);
});

// --- Tooltip UI: one shared div, CSS does all styling, JS only positions
// and toggles it.
var tip = document.createElement("div");
tip.className = "glossary-tooltip";
tip.setAttribute("role", "tooltip");
tip.hidden = true;
document.body.appendChild(tip);

var current = null;

function show(target) {
var def = terms[target.getAttribute("data-term")];
if (!def) return;
current = target;
tip.innerHTML = def;
tip.hidden = false;

var r = target.getBoundingClientRect();
var tw = tip.offsetWidth;
var th = tip.offsetHeight;
var left = r.left + window.scrollX + r.width / 2 - tw / 2;
var top = r.top + window.scrollY - th - 8;

// Flip below the term when there is no room above; clamp horizontally so
// the card never leaves the viewport (matters on narrow phones).
if (top < window.scrollY + 4) top = r.bottom + window.scrollY + 8;
left = Math.max(window.scrollX + 4, Math.min(left, window.scrollX + document.documentElement.clientWidth - tw - 4));

tip.style.left = left + "px";
tip.style.top = top + "px";
}

function hide() {
current = null;
tip.hidden = true;
}

article.addEventListener("mouseover", function (e) {
var t = e.target.closest && e.target.closest(".glossary-term");
if (t) show(t);
});
article.addEventListener("mouseout", function (e) {
var t = e.target.closest && e.target.closest(".glossary-term");
if (t && t === current && !(e.relatedTarget && tip.contains(e.relatedTarget))) hide();
});
article.addEventListener("focusin", function (e) {
if (e.target.classList && e.target.classList.contains("glossary-term")) show(e.target);
});
article.addEventListener("focusout", function (e) {
if (e.target.classList && e.target.classList.contains("glossary-term")) hide();
});
// Touch: tap toggles, tap elsewhere dismisses.
article.addEventListener("click", function (e) {
var t = e.target.closest && e.target.closest(".glossary-term");
if (t) {
if (t === current) { hide(); } else { show(t); }
e.preventDefault();
}
});
document.addEventListener("click", function (e) {
if (current && !tip.contains(e.target) && !(e.target.closest && e.target.closest(".glossary-term"))) hide();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") hide();
});
window.addEventListener("scroll", hide, { passive: true });
})();
4 changes: 4 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@
{# Mobile hamburger navigation drawer #}
<script src="/js/mobile-menu.js" defer></script>

{# Glossary tooltips: term data from data/glossary.json + auto-tagging UI #}
{% include "partials/glossary-data.html" %}
<script src="/js/glossary-tooltips.js" defer></script>

{# Light/dark theme toggle (persists to localStorage; dispatches "themechange") #}
<script src="/js/theme-toggle.js" defer></script>

Expand Down
13 changes: 13 additions & 0 deletions templates/partials/glossary-data.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{# Injects the glossary as a JS map for glossary-tooltips.js. Single source of
truth: edit data/glossary.json and every tooltip updates, no other change. #}
{% set data = load_data(path="data/glossary.json") %}
{# GraphQL (and any future term) has no definition yet — skip those so the
comma structure stays valid JS. #}
{% set defined = data.terms | filter(attribute="definition") %}
<script>
window.glossaryTerms = {
{%- for t in defined %}
{{ t.term | lower | json_encode | safe }}: {{ t.definition | markdown(inline=true) | json_encode | safe }}{% if not loop.last %},{% endif %}
{%- endfor %}
};
</script>
Loading