|
| 1 | +#!/usr/bin/env bash |
| 2 | +# scripts/docs-check-llms-txt.sh — semantic check for the generated |
| 3 | +# /llms.txt (https://llmstxt.org/) against its source of truth, |
| 4 | +# docs/data/nav.yml (see docs/layouts/home.llms.txt and docs/STYLE.md |
| 5 | +# "llms.txt"). A plain `hugo` build succeeding is not enough: a broken |
| 6 | +# groups[].items traversal (or similar template regression) can |
| 7 | +# silently drop entries — e.g. dropping from 99 to 64 with the |
| 8 | +# groups-only "Built-in Tools" and "Resources" sections left empty — |
| 9 | +# while the build itself stays green. This script builds the site and |
| 10 | +# asserts the *content* of public/llms.txt matches nav.yml exactly: |
| 11 | +# same sections, same order, same titles, same count, every link |
| 12 | +# matching nav.yml's URL at the SAME position (not merely "absolute |
| 13 | +# under the site baseURL", which a wrong-but-same-origin or duplicated |
| 14 | +# link would also satisfy), and every entry carrying a non-empty |
| 15 | +# (whitespace-only counts as empty) `: note`. |
| 16 | +# |
| 17 | +# Each entry's rendered URL is compared against the expected absolute |
| 18 | +# URL derived from nav.yml at the SAME index (BASE_URL joined with the |
| 19 | +# nav url) - a prefix-only check ("starts with BASE_URL") would pass |
| 20 | +# even if every entry rendered the identical link. |
| 21 | +# |
| 22 | +# The expected (title, url) list is built by parsing nav.yml |
| 23 | +# STRUCTURALLY, section by section (items first, then groups[].items), |
| 24 | +# not by independent greps over title:/url: lines in physical file |
| 25 | +# order: the template always emits items before groups regardless of |
| 26 | +# which key comes first in a section's YAML block, so a physical-order |
| 27 | +# grep would silently mismatch for a groups-before-items section. |
| 28 | +# |
| 29 | +# Bash-3.2-compatible (no mapfile/associative arrays), matching |
| 30 | +# scripts/workflow-lint.sh's portability note, so it also runs under |
| 31 | +# macOS's default /bin/bash. |
| 32 | + |
| 33 | +set -euo pipefail |
| 34 | + |
| 35 | +cd "$(dirname "$0")/.." |
| 36 | + |
| 37 | +BASE_URL="https://docker.github.io/docker-agent/" |
| 38 | +NAV="docs/data/nav.yml" |
| 39 | +LLMS="docs/public/llms.txt" |
| 40 | + |
| 41 | +echo "Building docs site..." |
| 42 | +(cd docs && hugo --gc --baseURL "$BASE_URL") |
| 43 | + |
| 44 | +[ -f "$LLMS" ] || { echo "missing $LLMS after build"; exit 1; } |
| 45 | + |
| 46 | +status=0 |
| 47 | +fail() { |
| 48 | + echo "FAIL: $1" >&2 |
| 49 | + status=1 |
| 50 | +} |
| 51 | + |
| 52 | +# (a) first line is the H1. |
| 53 | +first_line=$(sed -n '1p' "$LLMS") |
| 54 | +if [ "$first_line" != "# Docker Agent" ]; then |
| 55 | + fail "first line is not '# Docker Agent': ${first_line}" |
| 56 | +fi |
| 57 | + |
| 58 | +# (b) a non-empty '> ' blockquote summary follows (skipping the blank |
| 59 | +# line between the H1 and it). NR > 1 skips the H1 line in-process |
| 60 | +# instead of piping through `tail`: under `set -o pipefail`, an early |
| 61 | +# `exit` in a downstream awk/head/etc. can SIGPIPE a still-writing |
| 62 | +# upstream `tail`, making the whole pipeline intermittently exit 141. |
| 63 | +summary_line=$(awk 'NR > 1 && NF { print; exit }' "$LLMS") |
| 64 | +case "$summary_line" in |
| 65 | +"> "?*) ;; |
| 66 | +*) fail "no non-empty '> ' blockquote summary after the H1 (got: ${summary_line})" ;; |
| 67 | +esac |
| 68 | + |
| 69 | +# Expected data straight from nav.yml: section names (`- section:`) and |
| 70 | +# titles (`title:`, nested under either `items:` or `groups[].items:`), |
| 71 | +# both in file order — nav.yml's authored order is the contract. |
| 72 | +expected_sections=() |
| 73 | +while IFS= read -r line; do |
| 74 | + expected_sections+=("$line") |
| 75 | +done < <(grep -E '^- section:' "$NAV" | sed -E 's/^- section:[[:space:]]*//') |
| 76 | + |
| 77 | +# Structural per-section parse (see header comment above) producing |
| 78 | +# parallel title/url arrays in the exact order the template emits. |
| 79 | +expected_titles=() |
| 80 | +expected_urls=() |
| 81 | +while IFS=$'\t' read -r title url; do |
| 82 | + expected_titles+=("$title") |
| 83 | + expected_urls+=("$url") |
| 84 | +done < <(awk ' |
| 85 | + function unquote(s) { |
| 86 | + if (s ~ /^".*"$/) { |
| 87 | + sub(/^"/, "", s) |
| 88 | + sub(/"$/, "", s) |
| 89 | + } |
| 90 | + return s |
| 91 | + } |
| 92 | + # Flush the section just finished: its direct items, then its |
| 93 | + # groups[].items, in that order - matching the template exactly, |
| 94 | + # independent of which key was written first in this nav.yml block. |
| 95 | + function flush_section() { |
| 96 | + for (i = 0; i < ic; i++) print items_t[i] "\t" items_u[i] |
| 97 | + for (i = 0; i < gc; i++) print groups_t[i] "\t" groups_u[i] |
| 98 | + ic = 0 |
| 99 | + gc = 0 |
| 100 | + } |
| 101 | + /^- section:/ { |
| 102 | + flush_section() |
| 103 | + mode = "" |
| 104 | + next |
| 105 | + } |
| 106 | + /^ items:[[:space:]]*$/ { mode = "items"; next } |
| 107 | + /^ groups:[[:space:]]*$/ { mode = "groups"; next } |
| 108 | + mode == "items" && /^ - title:/ { |
| 109 | + t = $0 |
| 110 | + sub(/^ - title:[[:space:]]*/, "", t) |
| 111 | + pending_title = unquote(t) |
| 112 | + next |
| 113 | + } |
| 114 | + mode == "items" && /^ url:/ { |
| 115 | + u = $0 |
| 116 | + sub(/^ url:[[:space:]]*/, "", u) |
| 117 | + items_t[ic] = pending_title |
| 118 | + items_u[ic] = unquote(u) |
| 119 | + ic++ |
| 120 | + next |
| 121 | + } |
| 122 | + mode == "groups" && /^ - title:/ { |
| 123 | + t = $0 |
| 124 | + sub(/^ - title:[[:space:]]*/, "", t) |
| 125 | + pending_gtitle = unquote(t) |
| 126 | + next |
| 127 | + } |
| 128 | + mode == "groups" && /^ url:/ { |
| 129 | + u = $0 |
| 130 | + sub(/^ url:[[:space:]]*/, "", u) |
| 131 | + groups_t[gc] = pending_gtitle |
| 132 | + groups_u[gc] = unquote(u) |
| 133 | + gc++ |
| 134 | + next |
| 135 | + } |
| 136 | + END { flush_section() } |
| 137 | +' "$NAV") |
| 138 | + |
| 139 | +if [ "${#expected_urls[@]}" -ne "${#expected_titles[@]}" ]; then |
| 140 | + fail "${NAV} has ${#expected_titles[@]} title: entries but ${#expected_urls[@]} url: entries - expected a 1:1 pairing" |
| 141 | +fi |
| 142 | + |
| 143 | +# Sections declared via `groups:` (as opposed to a flat `items:`) — |
| 144 | +# these are exactly the ones a broken groups[].items traversal would |
| 145 | +# silently empty out (currently Built-in Tools, Resources). |
| 146 | +groups_sections=() |
| 147 | +while IFS= read -r line; do |
| 148 | + groups_sections+=("$line") |
| 149 | +done < <(awk ' |
| 150 | + /^- section:/ { s=$0; sub(/^- section:[[:space:]]*/, "", s) } |
| 151 | + /^ groups:/ { print s } |
| 152 | +' "$NAV") |
| 153 | + |
| 154 | +# (c) exactly 7 `## ` section headings, in nav.yml order. |
| 155 | +actual_sections=() |
| 156 | +while IFS= read -r line; do |
| 157 | + actual_sections+=("$line") |
| 158 | +done < <(grep -E '^## ' "$LLMS" | sed -E 's/^## //') |
| 159 | + |
| 160 | +if [ "${#expected_sections[@]}" -ne 7 ]; then |
| 161 | + fail "${NAV} defines ${#expected_sections[@]} sections, want 7" |
| 162 | +fi |
| 163 | +if [ "${#actual_sections[@]}" -ne "${#expected_sections[@]}" ]; then |
| 164 | + fail "${LLMS} has ${#actual_sections[@]} '## ' section(s), want ${#expected_sections[@]} (from ${NAV})" |
| 165 | +fi |
| 166 | +for i in "${!expected_sections[@]}"; do |
| 167 | + got="${actual_sections[$i]:-<missing>}" |
| 168 | + want="${expected_sections[$i]}" |
| 169 | + if [ "$got" != "$want" ]; then |
| 170 | + fail "section #$((i + 1)): got '${got}', want '${want}' (order must match ${NAV})" |
| 171 | + fi |
| 172 | +done |
| 173 | + |
| 174 | +# Entries: every `- [title](url): note` line, in file order. |
| 175 | +entry_lines=() |
| 176 | +while IFS= read -r line; do |
| 177 | + entry_lines+=("$line") |
| 178 | +done < <(grep -E '^- \[' "$LLMS") |
| 179 | + |
| 180 | +# (e) entry count and order match nav.yml's title: entries. |
| 181 | +if [ "${#entry_lines[@]}" -ne "${#expected_titles[@]}" ]; then |
| 182 | + fail "${LLMS} has ${#entry_lines[@]} entries, want ${#expected_titles[@]} (from ${NAV}) — a groups[].items or items traversal likely dropped entries" |
| 183 | +fi |
| 184 | + |
| 185 | +entry_pattern='^- \[([^]]*)\]\(([^)]*)\): (.+)$' |
| 186 | +for i in "${!entry_lines[@]}"; do |
| 187 | + line="${entry_lines[$i]}" |
| 188 | + |
| 189 | + # required '- [title](url): note' shape; title/url/note validated |
| 190 | + # individually below. |
| 191 | + if [[ "$line" =~ $entry_pattern ]]; then |
| 192 | + title="${BASH_REMATCH[1]}" |
| 193 | + url="${BASH_REMATCH[2]}" |
| 194 | + note="${BASH_REMATCH[3]}" |
| 195 | + else |
| 196 | + fail "entry #$((i + 1)) does not match '- [title](url): note': ${line}" |
| 197 | + continue |
| 198 | + fi |
| 199 | + |
| 200 | + want_title="${expected_titles[$i]:-<missing>}" |
| 201 | + if [ "$title" != "$want_title" ]; then |
| 202 | + fail "entry #$((i + 1)) title '${title}', want '${want_title}' (order mismatch vs ${NAV})" |
| 203 | + fi |
| 204 | + |
| 205 | + # (f) link matches the expected absolute URL for THIS position exactly |
| 206 | + # (BASE_URL + the nav url at the same index) - not merely "starts with |
| 207 | + # BASE_URL", which a wrong-but-same-origin (or duplicated) link would |
| 208 | + # also satisfy. |
| 209 | + want_url_path="${expected_urls[$i]:-}" |
| 210 | + if [ -z "$want_url_path" ]; then |
| 211 | + fail "entry #$((i + 1)) ('${title}') has no corresponding url: in ${NAV} at this index" |
| 212 | + else |
| 213 | + want_url="${BASE_URL}${want_url_path#/}" |
| 214 | + if [ "$url" != "$want_url" ]; then |
| 215 | + fail "entry #$((i + 1)) ('${title}') link '${url}' does not match expected '${want_url}' (position $((i + 1)) in ${NAV})" |
| 216 | + fi |
| 217 | + fi |
| 218 | + |
| 219 | + # (g) note must be non-empty once surrounding whitespace is trimmed, |
| 220 | + # so a whitespace-only description (e.g. " ") is treated as missing. |
| 221 | + trimmed_note="$(printf '%s' "$note" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" |
| 222 | + [ -n "$trimmed_note" ] || fail "entry #$((i + 1)) ('${title}') has an empty (or whitespace-only) note" |
| 223 | +done |
| 224 | + |
| 225 | +# (d) items and groups[].items are both flattened: every groups-based |
| 226 | +# section (Built-in Tools, Resources) must contribute a non-empty run |
| 227 | +# of entries between its heading and the next. |
| 228 | +for section in "${groups_sections[@]}"; do |
| 229 | + count=$(awk -v s="## ${section}" ' |
| 230 | + $0 == s { f=1; next } |
| 231 | + /^## / { if (f) exit } |
| 232 | + f && /^- \[/ { n++ } |
| 233 | + END { print n+0 } |
| 234 | + ' "$LLMS") |
| 235 | + if [ "$count" -eq 0 ]; then |
| 236 | + fail "section '${section}' (declared via groups: in ${NAV}) has no entries in ${LLMS}" |
| 237 | + fi |
| 238 | +done |
| 239 | + |
| 240 | +if [ "$status" -eq 0 ]; then |
| 241 | + echo "llms.txt OK: ${#entry_lines[@]} entries across ${#actual_sections[@]} sections, matching ${NAV}" |
| 242 | +fi |
| 243 | +exit "$status" |
0 commit comments