diff --git a/help/C/org.udpscope.app/math.page b/help/C/org.udpscope.app/math.page
index c2aecfa..0e207ec 100644
--- a/help/C/org.udpscope.app/math.page
+++ b/help/C/org.udpscope.app/math.page
@@ -89,8 +89,9 @@
are rebuilt whenever it changes.
- The math channel editor: nodes as boxes on a canvas, the orange dot marking the
- output, and the selected node's parameters beside it.
+ The math channel editor
+ Nodes as boxes on a canvas, the orange dot marking the output, and the selected
+ node's parameters beside it.
A sine source wired through a gain into a filter, with the output on the last
node.
diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page
index 5b6b6df..76da886 100644
--- a/help/C/org.udpscope.app/overview.page
+++ b/help/C/org.udpscope.app/overview.page
@@ -33,8 +33,9 @@
is still there when you need it — an export writes it as a second column.
- The udpscope window on a live source: the header bar above the plots, the side
- panel on the right, and the status bar along the bottom.
+ The udpscope window
+ The header bar above the plots, the side panel on the right, and the status bar
+ along the bottom.
One figure of three rows with four signals enabled, the Measure tab open in the
side panel, and the status bar reading live.
@@ -95,9 +96,9 @@
- A held capture: the trigger has latched, the state reads
- event held, and the axis is pinned to the event rather than to the live
- edge.
+ A held capture
+ The trigger has latched, the state reads event held, and the axis is
+ pinned to the event rather than to the live edge.
The trigger marker sits at x = 0 and the pre-trigger share of the window is drawn
to its left.
diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page
index 47923e5..1a76622 100644
--- a/help/C/org.udpscope.app/trigger.page
+++ b/help/C/org.udpscope.app/trigger.page
@@ -41,8 +41,9 @@
- A held capture: event held in the status bar, the axis pinned to the
- event, and the trigger marker at x = 0.
+ A held capture
+ event held in the status bar, the axis pinned to the event, and the
+ trigger marker at x = 0.
The pre-trigger share is drawn to the left of the marker.
diff --git a/tools/check_help.sh b/tools/check_help.sh
index 7a41627..cbedb59 100755
--- a/tools/check_help.sh
+++ b/tools/check_help.sh
@@ -3,12 +3,26 @@
# SPDX-FileCopyrightText: 2026 Martino Ferrari
#
# The manual's check, the way tools/check_ui.py is the .ui files' check: it runs the four
-# yelp-check subcommands that need no network, then renders the whole set to HTML with
-# yelp-build so a page that only fails at the transform step is caught too.
+# yelp-check subcommands that need no network, checks the structural rules below, then renders
+# the whole set to HTML with yelp-build so a page that only fails at the transform step is
+# caught too.
#
-# `yelp-check validate` is deliberately absent. It fetches the Mallard RelaxNG schema over
-# the network and fails on an offline machine for that reason rather than for a fault in the
-# pages -- links/media/orphans/ids are the offline checks.
+# `yelp-check validate` is deliberately absent, and it is worth being exact about why: it
+# builds the RelaxNG schema locally with `xsltproc` and `mal-rng.xsl`, but the schema it
+# generates includes the base grammar over the network, so on a machine where fetching is
+# blocked it fails for that reason rather than for a fault in the pages.
+#
+# What that leaves unverified is *structure*, and that is not a small gap: three pages
+# carrying a `` whose `` was a sentence and which had no `` opened in
+# nothing, while links, media, orphans, ids and the HTML render all passed. So the rules this
+# project has actually needed are checked here, with xmllint's XPath and no schema at all.
+# Add a rule when a page *teaches* one -- and do not add one you have not seen broken: a rule
+# that fails a legal page is worse than no rule.
+#
+# Every expression uses local-name() on purpose. A Mallard page declares
+# `xmlns="http://projectmallard.org/1.0/"`, and XPath 1.0 has no default namespace, so
+# `//figure` matches *nothing* and every rule below silently passes. Written naively, this
+# check reported "ok" for the very defect it was written for.
#
# Exit status is nonzero on the first finding.
set -eu
@@ -27,6 +41,29 @@ for sub in links media orphans ids; do
yelp-check "$sub" "$dir" >/dev/null
done
+failed=0
+for page in "$dir"/*.page; do
+ while IFS='|' read -r what expr; do
+ [ -n "$what" ] || continue
+ n=$(xmllint --xpath "$expr" "$page" 2>/dev/null || echo "?")
+ if [ "$n" != "0" ]; then
+ printf ' %s: %s (%s)\n' "$(basename "$page")" "$what" "$n" >&2
+ failed=1
+ fi
+ done <<'RULES'
+a figure describes itself|count(//*[local-name()='figure'][not(*[local-name()='desc'])])
+a section has a title|count(//*[local-name()='section'][not(*[local-name()='title'])])
+info is the page's first child|count(/*[local-name()='page']/*[1][local-name()!='info'])
+no text sits directly under page|count(/*[local-name()='page']/text()[normalize-space()])
+a media names its src|count(//*[local-name()='media'][not(@src)])
+RULES
+done
+if [ "$failed" != 0 ]; then
+ echo "check_help: structural findings above" >&2
+ exit 1
+fi
+printf 'structure %s: ok\n' "$dir"
+
mkdir -p "$tmp/html"
yelp-build html -o "$tmp/html" "$dir"
pages=$(find "$tmp/html" -maxdepth 1 -name '*.html' | wc -l)