Skip to content
Open
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
38 changes: 33 additions & 5 deletions .github/workflows/docs-lint.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Lints the documentation source in docs/**: markdownlint for style
# and lychee (offline) to verify that every relative Markdown link
# resolves to a real file. Portable relative links are what the Hugo
# module mount on docs.docker.com resolves, so broken ones would break
# the downstream build (see docs-upstream.yml).
# Lints the documentation source in docs/**: markdownlint for style,
# lychee (offline) to verify that every relative Markdown link resolves
# to a real file, and a semantic check of the generated /llms.txt
# (https://llmstxt.org/) against its data/nav.yml source of truth.
# Portable relative links are what the Hugo module mount on
# docs.docker.com resolves, so broken ones would break the downstream
# build (see docs-upstream.yml).
name: docs-lint

permissions:
Expand All @@ -19,11 +21,18 @@ on:
- ".github/workflows/docs-lint.yml"
- "docs/**"
- "scripts/docs-check-canonical.sh"
- "scripts/docs-check-llms-txt.sh"
pull_request:
paths:
- ".github/workflows/docs-lint.yml"
- "docs/**"
- "scripts/docs-check-canonical.sh"
- "scripts/docs-check-llms-txt.sh"

env:
# Kept in sync with docs-deploy.yml / docs-a11y.yml so every docs
# workflow builds with the same Hugo release.
HUGO_VERSION: 0.163.0

jobs:
markdownlint:
Expand Down Expand Up @@ -67,3 +76,22 @@ jobs:
--exclude-path docs/index.md --exclude-path docs/404.md
"docs/**/*.md"
fail: true

llms-txt-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Install Hugo
run: |
curl -fsSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz" \
| sudo tar -xz -C /usr/local/bin hugo
hugo version

# llms.txt is generated at build time from data/nav.yml (STYLE.md
# "llms.txt"); a broken groups[].items traversal or similar template
# regression can silently drop entries while the build stays green,
# so this asserts the built output's content, not just build success.
- name: Check llms.txt
run: ./scripts/docs-check-llms-txt.sh
17 changes: 17 additions & 0 deletions docs/STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ scaffolding a new page from an existing one. The homepage, `404.md`
and section `_index.md` files are not mirrored pages and don't set
one.

## llms.txt

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

## Availability badges

When a page documents a feature that is merged on `main` but not yet
Expand Down
13 changes: 13 additions & 0 deletions docs/hugo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ title: Docker Agent
# emitted at /404.html, which is where GitHub Pages looks for it.
disableKinds: [section, taxonomy, term, rss, sitemap, "404"]

# llms.txt (https://llmstxt.org/): a plain-text output format attached to
# the home page only. Without the explicit `outputs.home` entry below the
# format is defined but never rendered, since home currently emits HTML only.
outputFormats:
llms:
mediaType: text/plain
baseName: llms
isPlainText: true
permalinkable: false

outputs:
home: [html, llms]

params:
tagline: Run AI agents like containers.
description: Run AI agents from a YAML file. Define them once, share them through any OCI registry, and run them anywhere — by Docker.
Expand Down
1 change: 1 addition & 0 deletions docs/layouts/_partials/footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ <h2>Resources</h2>
<li><a href="https://github.com/docker/docker-agent" target="_blank" rel="noopener">GitHub</a></li>
<li><a href="https://hub.docker.com/u/agentcatalog" target="_blank" rel="noopener">Agent Catalog</a></li>
<li><a href="https://www.docker.com/blog/" target="_blank" rel="noopener">Blog</a></li>
<li><a href="{{ "llms.txt" | relURL }}">llms.txt</a></li>
</ul>
</div>
<div class="site-footer-col">
Expand Down
24 changes: 24 additions & 0 deletions docs/layouts/home.llms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Docker Agent

> {{ site.Params.description }}

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.
{{ range hugo.Data.nav }}
## {{ .section }}
{{ $items := slice -}}
{{ range .items }}{{ $items = $items | append . }}{{ end -}}
{{ range .groups }}{{ range .items }}{{ $items = $items | append . }}{{ end }}{{ end }}
{{- range $items }}
{{- $page := site.GetPage (strings.TrimSuffix "/" (strings.TrimPrefix "/" .url)) -}}
{{- $note := "" -}}
{{- if not $page -}}
{{- errorf "home.llms.txt: nav entry %q (%s) does not resolve to a page via site.GetPage" .title .url -}}
{{- else -}}
{{- $note = strings.TrimSpace $page.Description -}}
{{- if not $note -}}
{{- errorf "home.llms.txt: page %q (%s) is missing a front-matter description, required for its llms.txt note" .title .url -}}
{{- end -}}
{{- end -}}
- [{{ .title }}]({{ absURL (strings.TrimPrefix "/" .url) }}): {{ $note }}
{{ end -}}
{{ end }}
243 changes: 243 additions & 0 deletions scripts/docs-check-llms-txt.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
#!/usr/bin/env bash
# scripts/docs-check-llms-txt.sh — semantic check for the generated
# /llms.txt (https://llmstxt.org/) against its source of truth,
# docs/data/nav.yml (see docs/layouts/home.llms.txt and docs/STYLE.md
# "llms.txt"). A plain `hugo` build succeeding is not enough: a broken
# groups[].items traversal (or similar template regression) can
# silently drop entries — e.g. dropping from 99 to 64 with the
# groups-only "Built-in Tools" and "Resources" sections left empty —
# while the build itself stays green. This script builds the site and
# asserts the *content* of public/llms.txt matches nav.yml exactly:
# same sections, same order, same titles, same count, every link
# matching nav.yml's URL at the SAME position (not merely "absolute
# under the site baseURL", which a wrong-but-same-origin or duplicated
# link would also satisfy), and every entry carrying a non-empty
# (whitespace-only counts as empty) `: note`.
#
# Each entry's rendered URL is compared against the expected absolute
# URL derived from nav.yml at the SAME index (BASE_URL joined with the
# nav url) - a prefix-only check ("starts with BASE_URL") would pass
# even if every entry rendered the identical link.
#
# The expected (title, url) list is built by parsing nav.yml
# STRUCTURALLY, section by section (items first, then groups[].items),
# not by independent greps over title:/url: lines in physical file
# order: the template always emits items before groups regardless of
# which key comes first in a section's YAML block, so a physical-order
# grep would silently mismatch for a groups-before-items section.
#
# Bash-3.2-compatible (no mapfile/associative arrays), matching
# scripts/workflow-lint.sh's portability note, so it also runs under
# macOS's default /bin/bash.

set -euo pipefail

cd "$(dirname "$0")/.."

BASE_URL="https://docker.github.io/docker-agent/"
NAV="docs/data/nav.yml"
LLMS="docs/public/llms.txt"

echo "Building docs site..."
(cd docs && hugo --gc --baseURL "$BASE_URL")

[ -f "$LLMS" ] || { echo "missing $LLMS after build"; exit 1; }

status=0
fail() {
echo "FAIL: $1" >&2
status=1
}

# (a) first line is the H1.
first_line=$(sed -n '1p' "$LLMS")
if [ "$first_line" != "# Docker Agent" ]; then
fail "first line is not '# Docker Agent': ${first_line}"
fi

# (b) a non-empty '> ' blockquote summary follows (skipping the blank
# line between the H1 and it). NR > 1 skips the H1 line in-process
# instead of piping through `tail`: under `set -o pipefail`, an early
# `exit` in a downstream awk/head/etc. can SIGPIPE a still-writing
# upstream `tail`, making the whole pipeline intermittently exit 141.
summary_line=$(awk 'NR > 1 && NF { print; exit }' "$LLMS")
case "$summary_line" in
"> "?*) ;;
*) fail "no non-empty '> ' blockquote summary after the H1 (got: ${summary_line})" ;;
esac

# Expected data straight from nav.yml: section names (`- section:`) and
# titles (`title:`, nested under either `items:` or `groups[].items:`),
# both in file order — nav.yml's authored order is the contract.
expected_sections=()
while IFS= read -r line; do
expected_sections+=("$line")
done < <(grep -E '^- section:' "$NAV" | sed -E 's/^- section:[[:space:]]*//')

# Structural per-section parse (see header comment above) producing
# parallel title/url arrays in the exact order the template emits.
expected_titles=()
expected_urls=()
while IFS=$'\t' read -r title url; do
expected_titles+=("$title")
expected_urls+=("$url")
done < <(awk '
function unquote(s) {
if (s ~ /^".*"$/) {
sub(/^"/, "", s)
sub(/"$/, "", s)
}
return s
}
# Flush the section just finished: its direct items, then its
# groups[].items, in that order - matching the template exactly,
# independent of which key was written first in this nav.yml block.
function flush_section() {
for (i = 0; i < ic; i++) print items_t[i] "\t" items_u[i]
for (i = 0; i < gc; i++) print groups_t[i] "\t" groups_u[i]
ic = 0
gc = 0
}
/^- section:/ {
flush_section()
mode = ""
next
}
/^ items:[[:space:]]*$/ { mode = "items"; next }
/^ groups:[[:space:]]*$/ { mode = "groups"; next }
mode == "items" && /^ - title:/ {
t = $0
sub(/^ - title:[[:space:]]*/, "", t)
pending_title = unquote(t)
next
}
mode == "items" && /^ url:/ {
u = $0
sub(/^ url:[[:space:]]*/, "", u)
items_t[ic] = pending_title
items_u[ic] = unquote(u)
ic++
next
}
mode == "groups" && /^ - title:/ {
t = $0
sub(/^ - title:[[:space:]]*/, "", t)
pending_gtitle = unquote(t)
next
}
mode == "groups" && /^ url:/ {
u = $0
sub(/^ url:[[:space:]]*/, "", u)
groups_t[gc] = pending_gtitle
groups_u[gc] = unquote(u)
gc++
next
}
END { flush_section() }
' "$NAV")

if [ "${#expected_urls[@]}" -ne "${#expected_titles[@]}" ]; then
fail "${NAV} has ${#expected_titles[@]} title: entries but ${#expected_urls[@]} url: entries - expected a 1:1 pairing"
fi

# Sections declared via `groups:` (as opposed to a flat `items:`) —
# these are exactly the ones a broken groups[].items traversal would
# silently empty out (currently Built-in Tools, Resources).
groups_sections=()
while IFS= read -r line; do
groups_sections+=("$line")
done < <(awk '
/^- section:/ { s=$0; sub(/^- section:[[:space:]]*/, "", s) }
/^ groups:/ { print s }
' "$NAV")

# (c) exactly 7 `## ` section headings, in nav.yml order.
actual_sections=()
while IFS= read -r line; do
actual_sections+=("$line")
done < <(grep -E '^## ' "$LLMS" | sed -E 's/^## //')

if [ "${#expected_sections[@]}" -ne 7 ]; then
fail "${NAV} defines ${#expected_sections[@]} sections, want 7"
fi
if [ "${#actual_sections[@]}" -ne "${#expected_sections[@]}" ]; then
fail "${LLMS} has ${#actual_sections[@]} '## ' section(s), want ${#expected_sections[@]} (from ${NAV})"
fi
for i in "${!expected_sections[@]}"; do
got="${actual_sections[$i]:-<missing>}"
want="${expected_sections[$i]}"
if [ "$got" != "$want" ]; then
fail "section #$((i + 1)): got '${got}', want '${want}' (order must match ${NAV})"
fi
done

# Entries: every `- [title](url): note` line, in file order.
entry_lines=()
while IFS= read -r line; do
entry_lines+=("$line")
done < <(grep -E '^- \[' "$LLMS")

# (e) entry count and order match nav.yml's title: entries.
if [ "${#entry_lines[@]}" -ne "${#expected_titles[@]}" ]; then
fail "${LLMS} has ${#entry_lines[@]} entries, want ${#expected_titles[@]} (from ${NAV}) — a groups[].items or items traversal likely dropped entries"
fi

entry_pattern='^- \[([^]]*)\]\(([^)]*)\): (.+)$'
for i in "${!entry_lines[@]}"; do
line="${entry_lines[$i]}"

# required '- [title](url): note' shape; title/url/note validated
# individually below.
if [[ "$line" =~ $entry_pattern ]]; then
title="${BASH_REMATCH[1]}"
url="${BASH_REMATCH[2]}"
note="${BASH_REMATCH[3]}"
else
fail "entry #$((i + 1)) does not match '- [title](url): note': ${line}"
continue
fi

want_title="${expected_titles[$i]:-<missing>}"
if [ "$title" != "$want_title" ]; then
fail "entry #$((i + 1)) title '${title}', want '${want_title}' (order mismatch vs ${NAV})"
fi

# (f) link matches the expected absolute URL for THIS position exactly
# (BASE_URL + the nav url at the same index) - not merely "starts with
# BASE_URL", which a wrong-but-same-origin (or duplicated) link would
# also satisfy.
want_url_path="${expected_urls[$i]:-}"
if [ -z "$want_url_path" ]; then
fail "entry #$((i + 1)) ('${title}') has no corresponding url: in ${NAV} at this index"
else
want_url="${BASE_URL}${want_url_path#/}"
if [ "$url" != "$want_url" ]; then
fail "entry #$((i + 1)) ('${title}') link '${url}' does not match expected '${want_url}' (position $((i + 1)) in ${NAV})"
fi
fi

# (g) note must be non-empty once surrounding whitespace is trimmed,
# so a whitespace-only description (e.g. " ") is treated as missing.
trimmed_note="$(printf '%s' "$note" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
[ -n "$trimmed_note" ] || fail "entry #$((i + 1)) ('${title}') has an empty (or whitespace-only) note"
done

# (d) items and groups[].items are both flattened: every groups-based
# section (Built-in Tools, Resources) must contribute a non-empty run
# of entries between its heading and the next.
for section in "${groups_sections[@]}"; do
count=$(awk -v s="## ${section}" '
$0 == s { f=1; next }
/^## / { if (f) exit }
f && /^- \[/ { n++ }
END { print n+0 }
' "$LLMS")
if [ "$count" -eq 0 ]; then
fail "section '${section}' (declared via groups: in ${NAV}) has no entries in ${LLMS}"
fi
done

if [ "$status" -eq 0 ]; then
echo "llms.txt OK: ${#entry_lines[@]} entries across ${#actual_sections[@]} sections, matching ${NAV}"
fi
exit "$status"
Loading