Skip to content
Merged
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
3 changes: 2 additions & 1 deletion doc/api/python/extensions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ Re-reads a document when its sphinx-needs TOML file or the needs it imports chan
``need_cards``
----------------

The type colors and the status and verdict classes of the zdocs need cards.
The type colors, the status and verdict classes and the previews of the zdocs
need cards.

.. automodule:: need_cards
:members:
Expand Down
19 changes: 19 additions & 0 deletions doc/manual/reference/directives-and-roles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,25 @@ The ``need_cards`` extension (loaded by ``zdocs_conf``) gives each card the
class ``zd-status-<status>`` / ``zd-verdict-<verdict>`` that colors the pill.
A type without a color stays grey.

``need_cards`` also adds ``zdocs-need-preview.js`` to each page. When the
pointer stays on a link in a card, and the link goes to another need card, a
popup shows a copy of that card: the head, then the fields, then the text. The
link can go to a page of this document or of another one. The script fetches
the target page when the popup first opens and keeps it for the next link to
the same page. Only a link that goes to a need gets a popup: ``need_cards``
gives it the class ``zd-need-ref`` (its ``#fragment`` is the id of a need), so
a link to a Doxygen page (Source, See also) does not make the script fetch
that page. The copy has the type colors of the page that shows it, so all
documents of a project must use the same ``needs_config.toml``. They do so
already for the needs that they import from each other
(``ZDOCS_NEEDS_CONFIG``, :doc:`consumer-contract`).

The popup also opens on keyboard focus. Escape and a scroll of the page close
it. A link in the popup gets no popup of its own. A device without hover (a
touch screen) gets no popups. A browser does not let a page opened as
``file://`` fetch other pages, so there only the links to needs on the same
page get a popup; serve the HTML over HTTP to see all previews.

The engine puts the references of a generated need (Depends on, Source, See
also, Declared in) in a ``need-refs`` container: the card shows them as one
block under the text.
Expand Down
3 changes: 3 additions & 0 deletions sphinx/_extensions/_tests/roots/test-needcards/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ Need cards
.. spec:: A test case
:id: SPEC_1
:status: Passed / OK
:links: REQ_1

Source: `main.c <src/main_8c_source.html#l00042>`_
32 changes: 31 additions & 1 deletion sphinx/_extensions/_tests/test_need_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
from pathlib import Path

import pytest
from need_cards import CSS_NAME, status_class, type_colors_css
from need_cards import (
_JS_SOURCE,
CSS_NAME,
JS_NAME,
NEED_REF_CLASS,
status_class,
type_colors_css,
)

_ROOT = str(Path(__file__).parent / "roots" / "test-needcards")

Expand Down Expand Up @@ -43,6 +50,14 @@ def test_the_page_links_the_css_and_the_build_writes_it(app):
assert "needs_type_spec" not in css


@pytest.mark.sphinx("html", srcdir=_ROOT)
def test_the_page_loads_the_preview_script(app):
# The root has no zdocs_conf, so no zdocs _static in html_static_path.
app.build()
assert f'src="_static/{JS_NAME}' in (Path(app.outdir) / "index.html").read_text()
assert (Path(app.outdir) / "_static" / JS_NAME).read_text() == _JS_SOURCE.read_text()


@pytest.mark.parametrize(
"status, cls",
[("passed", "zd-status-passed"), ("Draft", "zd-status-draft"),
Expand All @@ -65,3 +80,18 @@ def test_each_need_table_gets_its_status_class(app):
assert "zd-status-draft" in req
assert "zd-status-passed-ok" in spec
assert "zd-verdict-" not in req # no verdict field: no class


@pytest.mark.sphinx("html", srcdir=_ROOT)
def test_only_a_link_to_a_need_gets_the_need_ref_class(app):
app.build()
html = (Path(app.outdir) / "index.html").read_text()
spec = html[html.index('id="SPEC_1"'):]

def link(href_part):
end = spec.index(href_part)
return spec[spec.rfind("<a ", 0, end):end]

assert NEED_REF_CLASS in link('href="#REQ_1"') # the links field of SPEC_1
assert NEED_REF_CLASS not in link("main_8c_source.html#l00042")
assert f"a.{NEED_REF_CLASS}" in _JS_SOURCE.read_text()
37 changes: 33 additions & 4 deletions sphinx/_extensions/need_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
``zd-status-<status>`` and ``zd-verdict-<verdict>`` to each need table that
has the field (``zd-status-passed``, ``zd-verdict-partial``), so that the card
can color the pill. The need data does not change.

**A preview of the linked need.** The extension adds ``zdocs-need-preview.js``
to each page. When the pointer stays on a link in a card, and the link goes
to another card, a popup shows a copy of that card. The script fetches the
target page in the browser, so it needs a page served over HTTP; a page
opened as ``file://`` gets previews of the needs on the same page only.
"""

import re
Expand All @@ -28,6 +34,8 @@
from docutils import nodes

CSS_NAME = "zdocs-need-types.css"
JS_NAME = "zdocs-need-preview.js"
_JS_SOURCE = Path(__file__).resolve().parents[1] / "_static" / JS_NAME

#: A CSS color as needs_config.toml gives it: #rgb(a) / #rrggbb(aa) or a name.
_COLOR = re.compile(r"^(#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})|[A-Za-z]+)$")
Expand All @@ -48,6 +56,11 @@ def type_colors_css(types):
return "\n".join(rules) + "\n"


#: The class of a link in a card that goes to a need: the preview script
#: takes only these links, and so fetches no page that has no card (a
#: Doxygen source listing or API page).
NEED_REF_CLASS = "zd-need-ref"

#: The fields that a card shows as a pill: the engine's status and verdict.
PILL_FIELDS = ("status", "verdict")

Expand Down Expand Up @@ -75,6 +88,15 @@ def _add_status_classes(app, doctree, docname):
cls = status_class(need.get(field), field)
if cls and cls not in table["classes"]:
table["classes"].append(cls)
_mark_need_refs(table, needs)


def _mark_need_refs(table, needs):
"""Give each link in ``table`` whose fragment is a need id `NEED_REF_CLASS`."""
for ref in table.findall(nodes.reference):
fragment = ref.get("refid") or ref.get("refuri", "").partition("#")[2]
if fragment and fragment in needs and NEED_REF_CLASS not in ref["classes"]:
ref["classes"].append(NEED_REF_CLASS)


def _write(app, exception):
Expand All @@ -88,17 +110,24 @@ def _write(app, exception):
path = static / CSS_NAME
if not path.exists() or path.read_text() != css:
path.write_text(css)
# zdocs_conf has the zdocs _static in html_static_path, so the file is
# there already; a document without zdocs_conf gets it here.
js = _JS_SOURCE.read_text()
path = static / JS_NAME
if not path.exists() or path.read_text() != js:
path.write_text(js)


def _add_css(app):
def _add_files(app):
if app.builder.format == "html":
app.add_css_file(CSS_NAME)
app.add_js_file(JS_NAME)


def setup(app):
app.connect("builder-inited", _add_css)
# After sphinx-needs replaced each need node with its table
# (process_need_nodes, doctree-resolved at the default priority 500).
app.connect("builder-inited", _add_files)
# After sphinx-needs replaced each need node with its table and resolved
# the links in it (doctree-resolved at the default priority 500).
app.connect("doctree-resolved", _add_status_classes, priority=600)
# After the HTML builder copied the static files, which it does at the end.
app.connect("build-finished", _write)
Expand Down
189 changes: 189 additions & 0 deletions sphinx/_static/zdocs-need-preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
* Copyright (c) 2026 inovex GmbH
*
* SPDX-License-Identifier: Apache-2.0
*/

/*
* A preview of the linked need: when the pointer stays on a link in a need
* card, and the link goes to another need card, a popup shows a copy of that
* card. The target page is fetched once and kept. A page opened as file://
* cannot fetch other pages; there only needs on the same page get a preview.
* The need_cards extension gives the links to a need the class zd-need-ref;
* the other links (Doxygen pages) get no popup, so no fetch.
*
* The popup is styled in zdocs-needs.css (.zd-preview). The need_cards
* extension adds this file to each page.
*/
(() => {
"use strict";

const SHOW_DELAY = 300; // ms the pointer stays on a link before the popup opens
const HIDE_DELAY = 250; // ms to move the pointer from the link into the popup
// need_cards gives this class to the links that go to a need.
const LINKS = "table.need a.zd-need-ref";

const pages = new Map(); // page URL -> Promise<Document | null>
let popup = null;
let link = null; // the link the popup is for, or will be for
let showTimer = 0;
let hideTimer = 0;

function pageUrl(url) {
const u = new URL(url, location.href);
u.hash = "";
return u.href;
}

function load(page) {
if (page === pageUrl(location.href)) return Promise.resolve(document);
if (!pages.has(page)) {
pages.set(
page,
fetch(page)
.then((r) => (r.ok ? r.text() : Promise.reject(r.status)))
.then((text) => new DOMParser().parseFromString(text, "text/html"))
.catch(() => null),
);
}
return pages.get(page);
}

// A copy of the card with the id `id` in `doc`, or null if `id` is no card.
function card(doc, id, page) {
const found = doc.getElementById(id);
if (!found || !found.matches("table.need")) return null;
const copy = found.cloneNode(true);
// The page has the ids already; the copy keeps none.
copy.removeAttribute("id");
copy.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id"));
if (doc !== document) {
// Relative to the target page, not to this one.
copy.querySelectorAll("a[href]").forEach((a) => {
a.href = new URL(a.getAttribute("href"), page).href;
});
copy.querySelectorAll("img[src]").forEach((img) => {
img.src = new URL(img.getAttribute("src"), page).href;
});
}
return copy;
}

function target(a) {
const url = new URL(a.getAttribute("href"), location.href);
const id = decodeURIComponent(url.hash.slice(1));
if (!id) return null;
// The id link of a card goes to the card itself.
const own = a.closest("table.need");
if (own && own.id === id && pageUrl(url.href) === pageUrl(location.href)) return null;
return { page: pageUrl(url.href), id };
}

function ensurePopup() {
if (popup) return popup;
popup = document.createElement("div");
popup.className = "zd-preview";
popup.setAttribute("role", "tooltip");
popup.hidden = true;
popup.addEventListener("mouseenter", () => clearTimeout(hideTimer));
popup.addEventListener("mouseleave", scheduleHide);
// In the content area: the card rules of zdocs-needs.css start there.
(document.querySelector(".rst-content") || document.body).appendChild(popup);
return popup;
}

function place(a) {
const gap = 6;
const margin = 16;
const r = a.getBoundingClientRect();
const p = popup.getBoundingClientRect();
const below = window.innerHeight - r.bottom - gap - margin;
const above = r.top - gap - margin;
const top = p.height <= below || below >= above ? r.bottom + gap : r.top - gap - p.height;
const left = Math.min(Math.max(margin, r.left), window.innerWidth - margin - p.width);
popup.style.top = `${Math.max(margin, top)}px`;
popup.style.left = `${Math.max(margin, left)}px`;
}

function show(a) {
const t = target(a);
if (!t) return;
load(t.page).then((doc) => {
if (link !== a || !doc) return; // the pointer has gone, or no fetch
const copy = card(doc, t.id, t.page);
if (!copy) return;
ensurePopup();
popup.replaceChildren(copy);
popup.scrollTop = 0;
popup.style.top = "0px";
popup.style.left = "0px";
popup.hidden = false;
place(a);
});
}

function hide() {
clearTimeout(showTimer);
link = null;
if (popup) popup.hidden = true;
}

function scheduleHide() {
clearTimeout(showTimer);
clearTimeout(hideTimer);
hideTimer = setTimeout(hide, HIDE_DELAY);
}

function enter(a) {
clearTimeout(hideTimer);
if (a === link) return;
clearTimeout(showTimer);
if (popup) popup.hidden = true;
link = a;
showTimer = setTimeout(() => show(a), SHOW_DELAY);
}

// A link in a card, but not one in the popup: no preview of a preview.
function previewLink(el) {
const a = el instanceof Element ? el.closest(LINKS) : null;
return a && !a.closest(".zd-preview") ? a : null;
}

function init() {
document.addEventListener("mouseover", (e) => {
const a = previewLink(e.target);
if (a) enter(a);
});
document.addEventListener("mouseout", (e) => {
const a = previewLink(e.target);
if (a && !a.contains(e.relatedTarget)) scheduleHide();
});
document.addEventListener("focusin", (e) => {
const a = previewLink(e.target);
if (a) enter(a);
});
document.addEventListener("focusout", (e) => {
if (previewLink(e.target)) scheduleHide();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") hide();
});
// The popup is fixed; a scroll of the page moves the link away from it.
document.addEventListener(
"scroll",
(e) => {
if (popup && !popup.hidden && !popup.contains(e.target)) hide();
},
true,
);
}

// Only with a pointer that can hover; a tap follows the link.
if (window.matchMedia("(hover: hover)").matches) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}
})();
Loading