diff --git a/qa-tooltips.py b/qa-tooltips.py new file mode 100644 index 0000000..eb2ceaa --- /dev/null +++ b/qa-tooltips.py @@ -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) diff --git a/sass/style.scss b/sass/style.scss index 856b936..53ab004 100644 --- a/sass/style.scss +++ b/sass/style.scss @@ -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; + } +} diff --git a/static/js/glossary-tooltips.js b/static/js/glossary-tooltips.js new file mode 100644 index 0000000..873d7a9 --- /dev/null +++ b/static/js/glossary-tooltips.js @@ -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 }); +})(); diff --git a/templates/base.html b/templates/base.html index 49f1322..1d42fe0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -128,6 +128,10 @@ {# Mobile hamburger navigation drawer #} +{# Glossary tooltips: term data from data/glossary.json + auto-tagging UI #} +{% include "partials/glossary-data.html" %} + + {# Light/dark theme toggle (persists to localStorage; dispatches "themechange") #} diff --git a/templates/partials/glossary-data.html b/templates/partials/glossary-data.html new file mode 100644 index 0000000..bcf34a7 --- /dev/null +++ b/templates/partials/glossary-data.html @@ -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") %} +