Skip to content

Commit 55b9456

Browse files
committed
feat(docs): generate self-maintaining llms.txt from nav.yml
Add a spec-compliant (https://llmstxt.org/) /llms.txt to the edge docs site, generated at Hugo build time from docs/data/nav.yml so it stays in sync as pages are added — deployed by the existing docs-deploy workflow with no new CI or cron. - hugo.yaml: register a plain-text 'llms' output format attached to the home page (outputs.home) so it is actually rendered. - layouts/home.llms.txt: template emitting the H1, blockquote summary, and one H2 per nav section with '- [title](url): note' links (absolute URLs via absURL; note from each page's front-matter description). Flattens both items and groups[].items, and hard-fails the build when a nav page is unresolved or its description is empty. - layouts/_partials/footer.html: add an llms.txt discoverability link. - scripts/docs-check-llms-txt.sh: CI-invoked semantic validator that rebuilds the site and asserts llms.txt matches nav.yml's sections, titles, order, count and per-entry URL — catching template regressions a green build would miss. - docs-lint.yml: wire in the new llms-txt-check job (pinned Hugo). - STYLE.md: document generation, the description requirement, and CI.
1 parent bf7ca85 commit 55b9456

6 files changed

Lines changed: 331 additions & 5 deletions

File tree

.github/workflows/docs-lint.yml

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
# Lints the documentation source in docs/**: markdownlint for style
2-
# and lychee (offline) to verify that every relative Markdown link
3-
# resolves to a real file. Portable relative links are what the Hugo
4-
# module mount on docs.docker.com resolves, so broken ones would break
5-
# the downstream build (see docs-upstream.yml).
1+
# Lints the documentation source in docs/**: markdownlint for style,
2+
# lychee (offline) to verify that every relative Markdown link resolves
3+
# to a real file, and a semantic check of the generated /llms.txt
4+
# (https://llmstxt.org/) against its data/nav.yml source of truth.
5+
# Portable relative links are what the Hugo module mount on
6+
# docs.docker.com resolves, so broken ones would break the downstream
7+
# build (see docs-upstream.yml).
68
name: docs-lint
79

810
permissions:
@@ -19,11 +21,18 @@ on:
1921
- ".github/workflows/docs-lint.yml"
2022
- "docs/**"
2123
- "scripts/docs-check-canonical.sh"
24+
- "scripts/docs-check-llms-txt.sh"
2225
pull_request:
2326
paths:
2427
- ".github/workflows/docs-lint.yml"
2528
- "docs/**"
2629
- "scripts/docs-check-canonical.sh"
30+
- "scripts/docs-check-llms-txt.sh"
31+
32+
env:
33+
# Kept in sync with docs-deploy.yml / docs-a11y.yml so every docs
34+
# workflow builds with the same Hugo release.
35+
HUGO_VERSION: 0.163.0
2736

2837
jobs:
2938
markdownlint:
@@ -67,3 +76,22 @@ jobs:
6776
--exclude-path docs/index.md --exclude-path docs/404.md
6877
"docs/**/*.md"
6978
fail: true
79+
80+
llms-txt-check:
81+
runs-on: ubuntu-latest
82+
steps:
83+
- name: Checkout
84+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
85+
86+
- name: Install Hugo
87+
run: |
88+
curl -fsSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz" \
89+
| sudo tar -xz -C /usr/local/bin hugo
90+
hugo version
91+
92+
# llms.txt is generated at build time from data/nav.yml (STYLE.md
93+
# "llms.txt"); a broken groups[].items traversal or similar template
94+
# regression can silently drop entries while the build stays green,
95+
# so this asserts the built output's content, not just build success.
96+
- name: Check llms.txt
97+
run: ./scripts/docs-check-llms-txt.sh

docs/STYLE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,23 @@ scaffolding a new page from an existing one. The homepage, `404.md`
9393
and section `_index.md` files are not mirrored pages and don't set
9494
one.
9595

96+
## llms.txt
97+
98+
`/llms.txt` is generated at build time from `data/nav.yml` (see
99+
`layouts/home.llms.txt`): adding a page to the nav automatically adds
100+
it to llms.txt, and pages absent from `nav.yml` never appear there.
101+
Every nav entry must resolve to a real page with a non-empty
102+
`description:` in its front matter (whitespace-only counts as empty)
103+
— the build fails with an `errorf` naming the offending title/url
104+
otherwise, since the spec's `- [title](url): note` shape requires a
105+
note. CI (`docs-lint` / `scripts/docs-check-llms-txt.sh`) additionally
106+
rebuilds the site and asserts the generated `llms.txt` matches
107+
`nav.yml`'s sections, titles, order, count **and per-entry URL**
108+
(each rendered link must match the nav url at the same position, not
109+
just share the site's base URL), catching regressions (e.g. a broken
110+
`groups[].items` traversal, or every entry rendering the same link)
111+
that a successful build alone would not.
112+
96113
## Availability badges
97114

98115
When a page documents a feature that is merged on `main` but not yet

docs/hugo.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ title: Docker Agent
1515
# emitted at /404.html, which is where GitHub Pages looks for it.
1616
disableKinds: [section, taxonomy, term, rss, sitemap, "404"]
1717

18+
# llms.txt (https://llmstxt.org/): a plain-text output format attached to
19+
# the home page only. Without the explicit `outputs.home` entry below the
20+
# format is defined but never rendered, since home currently emits HTML only.
21+
outputFormats:
22+
llms:
23+
mediaType: text/plain
24+
baseName: llms
25+
isPlainText: true
26+
permalinkable: false
27+
28+
outputs:
29+
home: [html, llms]
30+
1831
params:
1932
tagline: Run AI agents like containers.
2033
description: Run AI agents from a YAML file. Define them once, share them through any OCI registry, and run them anywhere — by Docker.

docs/layouts/_partials/footer.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ <h2>Resources</h2>
2525
<li><a href="https://github.com/docker/docker-agent" target="_blank" rel="noopener">GitHub</a></li>
2626
<li><a href="https://hub.docker.com/u/agentcatalog" target="_blank" rel="noopener">Agent Catalog</a></li>
2727
<li><a href="https://www.docker.com/blog/" target="_blank" rel="noopener">Blog</a></li>
28+
<li><a href="{{ "llms.txt" | relURL }}">llms.txt</a></li>
2829
</ul>
2930
</div>
3031
<div class="site-footer-col">

docs/layouts/home.llms.txt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Docker Agent
2+
3+
> {{ site.Params.description }}
4+
5+
These docs track the `main` branch and may describe unreleased features. The stable documentation lives at https://docs.docker.com/ai/docker-agent/, which publishes its own https://docs.docker.com/llms.txt.
6+
{{ range hugo.Data.nav }}
7+
## {{ .section }}
8+
{{ $items := slice -}}
9+
{{ range .items }}{{ $items = $items | append . }}{{ end -}}
10+
{{ range .groups }}{{ range .items }}{{ $items = $items | append . }}{{ end }}{{ end }}
11+
{{- range $items }}
12+
{{- $page := site.GetPage (strings.TrimSuffix "/" (strings.TrimPrefix "/" .url)) -}}
13+
{{- $note := "" -}}
14+
{{- if not $page -}}
15+
{{- errorf "home.llms.txt: nav entry %q (%s) does not resolve to a page via site.GetPage" .title .url -}}
16+
{{- else -}}
17+
{{- $note = strings.TrimSpace $page.Description -}}
18+
{{- if not $note -}}
19+
{{- errorf "home.llms.txt: page %q (%s) is missing a front-matter description, required for its llms.txt note" .title .url -}}
20+
{{- end -}}
21+
{{- end -}}
22+
- [{{ .title }}]({{ absURL (strings.TrimPrefix "/" .url) }}): {{ $note }}
23+
{{ end -}}
24+
{{ end }}

scripts/docs-check-llms-txt.sh

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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

Comments
 (0)