#!/usr/bin/env bash # The manual. # # docs/ in this repository is engineering artifacts: design specs, plans, an # upstream ledger. None of it is written for the person using the desktop, and # that person is the one with questions. The manual is the answer, and it is # rendered inside Settings so a chapter can point at a settings page and have # that mean something. # # What must hold: # # 1. Every chapter the page lists exists, and every chapter file is listed. # A renamed file shows an error card in place of a chapter, which looks # like the manual is broken rather than like somebody moved a file. # 2. Chapters render one at a time. Text has an implicit texture size limit, # and a document long enough to hit it goes blank rather than complaining. # 3. Links leave the desktop rather than doing nothing. # 4. The page is registered everywhere a settings page has to be, or it # silently redirects to Home. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" manual_dir="$repo_dir/config/dot/quickshell/manual" page="$repo_dir/config/dot/quickshell/modules/settings/ManualPage.qml" # The chapter list and the titles live in one place that both the reader and # About's Manual card instantiate, so neither can drift from the other. contents="$repo_dir/config/dot/quickshell/modules/settings/ManualChapters.qml" routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml" shell_ui="$repo_dir/config/dot/quickshell/modules/settings/SettingsShell.qml" qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir" state="$repo_dir/config/dot/quickshell/services/ShellState.qml" search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" findings=() note() { findings+=("$1"); } [[ -d "$manual_dir" ]] || { printf 'manual contract: %s is missing\n' "$manual_dir" >&2; exit 1; } # ── 1. The chapters on disk and the chapters listed are the same set ───────── mapfile -t on_disk < <(find "$manual_dir" -maxdepth 1 -name '*.md' -printf '%f\n' | sort) (( ${#on_disk[@]} > 0 )) || note 'the manual has no chapters' [[ -r "$contents" ]] \ || note 'ManualChapters.qml is missing, so neither the reader nor About knows what the chapters are' mapfile -t listed < <(grep -oE 'file: "[^"]+\.md"' "$contents" | sed 's/file: "//; s/"//' | sort) (( ${#listed[@]} > 0 )) || note 'the chapter list names no chapters' for file in "${on_disk[@]}"; do printf '%s\n' "${listed[@]}" | grep -qx "$file" \ || note "$file exists but the chapter list never names it" done for file in "${listed[@]}"; do [[ -r "$manual_dir/$file" ]] \ || note "the chapter list names $file, which does not exist, so that chapter renders an error" done # A chapter that is only a heading is a chapter somebody forgot to write. for file in "${on_disk[@]}"; do lines="$(grep -c . "$manual_dir/$file" || true)" (( lines > 10 )) || note "$file has $lines lines; it reads as unfinished" head -1 "$manual_dir/$file" | grep -q '^# ' \ || note "$file does not begin with a heading, so it has no title of its own" done # ── 1b. The tab titles come from the files ─────────────────────────────────── # # The page carried a comment saying the title shown is the first heading of # each chapter, "so a chapter cannot be renamed in one place and not the # other". It was not true: the titles were a second copy, written beside the # filenames, and the two agreed only because nobody had renamed anything yet. # A comment that describes a property the code does not have is worse than no # comment, because it is the reason the next person does not check. # # So: no authored label beside a chapter file, and something that actually # reads a leading heading out of one. python3 - "$contents" <<'PY' || note 'the chapter list still carries hand-written titles beside the filenames, so a renamed heading and a tab label can disagree' import re import sys text = open(sys.argv[1], encoding="utf-8").read() block = re.search(r'chapters:\s*\[(.*?)\n \]', text, re.S) if not block: raise SystemExit(1) raise SystemExit(1 if re.search(r'label:\s*"', block.group(1)) else 0) PY grep -qE '\^#' "$contents" \ || note 'nothing reads a leading heading, so the chapter titles cannot be coming from the files' grep -q 'Quickshell.shellDir + "/manual/"' "$contents" \ || note 'the chapter titles are not read through the shell directory, so they would break on a clone elsewhere' grep -q 'ManualChapters {' "$page" \ || note 'the manual reader keeps its own idea of what the chapters are' # And the titles the files offer have to be usable as tab labels: a first line # that is a paragraph would render a tab strip nobody can read. for file in "${on_disk[@]}"; do heading="$(head -1 "$manual_dir/$file" | sed 's/^#\+[[:space:]]*//')" [[ -n "$heading" ]] || note "$file has an empty first heading, so its tab would have no name" (( ${#heading} <= 40 )) \ || note "$file's first heading is ${#heading} characters; it is the tab label, so it has to be a title" done # ── 2 & 3. How it renders ──────────────────────────────────────────────────── grep -q 'textFormat: Text.MarkdownText' "$page" \ || note 'chapters are not rendered as markdown, so the source appears verbatim' grep -q 'root.chapters\[root.current\]' "$page" \ || note 'the page does not render one chapter at a time; a single long Text goes blank rather than erroring' grep -q 'onLinkActivated' "$page" \ || note 'links in the manual do nothing when clicked' # ── 3b. A link to a settings page opens the settings page ──────────────────── # # The whole reason the manual is rendered inside Settings rather than in a # browser is that a chapter can point at a page and have that mean something. # Every link went to Qt.openUrlExternally, so "open Displays" handed a # panama:// URL to xdg-open, which has no handler for it: the click did # nothing, silently, which is the worst of the three possible outcomes. grep -q 'panama://settings/' "$page" \ || note 'the manual has no in-app link scheme, so a chapter cannot point at a settings page' python3 - "$page" <<'PY' || note 'the manual link handler does not route in-app links through ShellState.openSettings while still sending everything else out of the desktop' import re import sys text = open(sys.argv[1], encoding="utf-8").read() def block_at(index: int) -> str: """The braced body starting at the first { on or after index.""" start = text.index("{", index) depth = 0 for position in range(start, len(text)): if text[position] == "{": depth += 1 elif text[position] == "}": depth -= 1 if depth == 0: return text[start:position + 1] raise SystemExit(1) # Either shape is fine: the branch written inline, or delegated to a named # function on the page. What matters is what is on the path, so the path is # followed rather than one line being read and guessed at. handler = re.search(r'onLinkActivated:\s*link\s*=>\s*(?P.)', text) if not handler: raise SystemExit(1) if handler.group("tail") == "{": body = block_at(handler.start("tail")) else: called = re.match(r'(?:root\.)?([A-Za-z][A-Za-z0-9]*)\(', text[handler.start("tail"):]) if not called: raise SystemExit(1) declaration = re.search(rf'function {called.group(1)}\s*\(', text) if not declaration: raise SystemExit(1) body = block_at(declaration.end()) raise SystemExit(0 if "ShellState.openSettings" in body and "Qt.openUrlExternally" in body and ("panama://settings/" in body or "linkScheme" in body) else 1) PY # Every in-app link a chapter actually writes must name a page that exists. # A typo here is invisible: the scheme matches, the handler fires, and # openSettings resolves an unknown id to Home -- so the link works, just not # the way the sentence around it promised. leaves="$( { grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes" grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" sed -n '/property var hiddenLeaves:/,/\]/p' "$routes" | grep -oE 'page: "[a-z-]+"' } | sed -E 's/.*page: "([a-z-]+)".*/\1/' | sort -u)" [[ -n "$leaves" ]] || note 'no leaves could be read from SettingsRoutes, so in-app manual links prove nothing' while read -r target; do [[ -n "$target" ]] || continue grep -qx "$target" <<<"$leaves" \ || note "a chapter links to panama://settings/$target, which is not a page anyone can land on" done < <(grep -rhoE 'panama://settings/[a-z-]+' "$manual_dir" \ | sed -E 's|panama://settings/||' | sort -u) grep -q 'onLoadFailed' "$page" \ || note 'a chapter that cannot be read fails silently instead of saying so' # The chapters are reached through the shell directory, not by walking upward # out of it: that path is only correct when the repository is where it usually # is, and the shell directory is a symlink. grep -q 'Quickshell.shellDir + "/manual/"' "$page" \ || note 'the manual is not located through the shell directory, so it would break on a clone elsewhere' grep -q '\.\./\.\./\.\.' "$page" \ && note 'the manual path walks upward out of the shell directory, which is only correct by accident' # ── 4. Registered everywhere a settings page has to be ─────────────────────── # The manual is a routable leaf with no tab of its own. It was a tab of System # until the strip reached ten and had to come back down to eight, and it is the # right one to lose: it is reference material rather than a control surface, so # it is opened deliberately -- from About's Manual card, from a deep link, from # a search result -- rather than found by scanning a row of tabs. # # That makes the taxonomy entry the only thing holding it up. A leaf missing # from SettingsRoutes cannot be opened, searched, or linked to, and because it # draws no tab anywhere, nothing on screen would show it had gone. python3 - "$routes" <<'PY' || note 'the manual is not a hidden leaf in SettingsRoutes, so nothing can navigate to it' import re import sys text = open(sys.argv[1], encoding="utf-8").read() block = re.search(r'hiddenLeaves:\s*\[(.*?)\]', text, re.S) raise SystemExit(0 if block and re.search(r'page: "manual"', block.group(1)) else 1) PY python3 - "$routes" <<'PY' || note 'the manual is a System tab again, taking back a slot the eight-tab strip does not have' import re import sys text = open(sys.argv[1], encoding="utf-8").read() block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S) raise SystemExit(1 if block and '{ page: "manual"' in block.group(1) else 0) PY grep -q 'case "manual": return manualPage;' "$shell_ui" || note 'SettingsShell does not route to the manual' grep -q 'Component { id: manualPage; ManualPage {} }' "$shell_ui" || note 'SettingsShell never declares the manual component' grep -q '^ManualPage 1.0 ManualPage.qml$' "$qmldir" || note 'ManualPage is not registered in the settings qmldir' # ShellState keeps no page list of its own any more; it resolves whatever it is # handed through SettingsRoutes. That is what makes the taxonomy check above # sufficient, so it is worth pinning that it stays that way. grep -q 'SettingsRoutes.resolve(' "$state" || note 'ShellState does not resolve pages through SettingsRoutes, so openSettings("manual") has no defined destination' grep -q 'page: "manual"' "$search" || note 'the manual is not searchable from the settings search box' # With no tab of its own, About's Manual card is the only place the manual is # offered rather than looked up. If that card stops opening it, the page is # still reachable in principle and undiscoverable in practice. about="$repo_dir/config/dot/quickshell/modules/settings/AboutPage.qml" if [[ -r "$about" ]]; then grep -q 'openSettings("manual"' "$about" \ || note 'About does not open the manual, which is now the only place it is offered rather than searched for' grep -q 'ManualChapters {' "$about" \ || note 'About keeps its own idea of what the chapters are, so its card and the reader can disagree' grep -qE 'file: "[^"]+\.md"' "$about" \ && note 'About names chapter files itself instead of reading the shared list' else note 'AboutPage.qml is missing, so the manual has nowhere to be opened from' fi if (( ${#findings[@]} > 0 )); then printf 'manual contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'manual contract: PASS (%d chapters)\n' "${#on_disk[@]}"