Skip to content

Generate sitetree.json, llms.txt, llms-full.txt, and a tutorials catalog at build time - #5312

Merged
Jeremy Rose (jeremyrose-viam) merged 21 commits into
viamrobotics:mainfrom
jeremyrose-viam:sitetree-llms-txt-v2
Sep 14, 2026
Merged

Jeremy Rose (jeremyrose-viam) merged 21 commits into
viamrobotics:mainfrom
jeremyrose-viam:sitetree-llms-txt-v2

Conversation

@jeremyrose-viam

@jeremyrose-viam Jeremy Rose (jeremyrose-viam) commented Sep 11, 2026

Copy link
Copy Markdown
Member

Summary

Adds four build-time-generated, agent-facing discoverability files: /sitetree.json, /llms.txt, /llms-full.txt, and /tutorials/catalog.md. This is a redesign of #5311 (closing that PR in favor of this one) — that approach used custom Hugo output formats for all three original files and hit three real bugs along the way (a template naming collision that silently replaced every section's real Markdown mirror content sitewide, a shortcode-rendering quirk that needed a mediaType trick plus a Netlify redirect, and a misdiagnosed root cause for an HTML-escaping bug). Rather than patch forward, the mechanism was reconsidered from scratch against a stricter bar: minimum blast radius on the existing Markdown mirror (#5307), Hugo/Docsy-native where possible, no Netlify hacks if avoidable, and "set and forget" maintainability.

Mechanism: sitetree.json/llms.txt/llms-full.txt are generated via Hugo Pipes (resources.Get | resources.ExecuteAsTemplate — the same mechanism the Docsy theme itself uses for its own offline-search-index.json), not custom output formats. Pipes assets never touch Hugo's list.<format> template lookup at all, so the collision class that broke #5311 is structurally impossible here, not just avoided by discipline. /tutorials/catalog.md is the one exception, generated as a Hugo output format instead, since its natural URL is already .md and it directly reuses list.typesense.json's existing filter/canonical-URL logic — a genuine, well-precedented fit rather than a default choice.

  • /sitetree.json — full site IA as JSON, mirroring layouts/partials/sidebar-tree.html's (this repo's own override, not the Docsy vendor copy) child-selection logic. Schema change from Generate sitetree.json, llms.txt, and llms-full.txt at build time #5311: a node's path is present only when it has real content of its own; a pure-signpost section carries redirect instead, so an agent can't accidentally fetch an empty stub, and no URL appears twice in the tree.
  • /llms.txt — curated entry point from data/llms_pages.yaml, unchanged curation approach from Generate sitetree.json, llms.txt, and llms-full.txt at build time #5311.
  • /llms-full.txt — full text of curated "orientation" pages, reusing render-page-markdown.html unchanged. No mediaType trick or Netlify redirect needed this time — real win of the Pipes approach. Deliberately not linked from every page's <head> (only from llms.txt's own body, which can carry the context that "-full" doesn't mean "the whole site").
  • /tutorials/catalog.md — new. A flat, non-hierarchical index of all 37 currently-listed tutorials (verified matching count against the existing typesense.json index), since 21 of 48 tutorial pages point off-site and don't fit sitetree.json's single-parent hierarchy.

CLAUDE.md's "Agent discoverability files" section documents the whole system, including two genuinely non-obvious Hugo Pipes behaviors found during implementation:

  • A Pipes resource's shortcode-template resolution (clean .md variant vs. raw .html) follows whichever output format's render pass is actually executing the ExecuteAsTemplate call — not the resource's name, not the target-path string. llms-full.txt's trigger had to move from head.html (always HTML) into list.markdown.md (always MARKDOWN) to get clean shortcode rendering.
  • A Pipes resource only publishes if something dereferences a property on it (.Permalink, etc.) — executing the template isn't enough. Since llms-full.txt is deliberately unlinked from <head>, its trigger in list.markdown.md carries a one-line, no-visible-output reference purely to force publishing.

Also carries forward the render-page-markdown.html extraction (cherry-picked from #5311's own already-fixed commits) and the list.mdlist.markdown.md rename — both land on main for the first time here, since #5311 was never merged.

Test plan

  • make build-prod completes with no errors
  • vale (pinned 3.12.0, matching CI) clean on every .md file this PR touches or creates
  • Full-site regression sweep: all 90 section/home Markdown mirrors checked for unexpected content, zero cross-contamination between any of the four new files and the pre-existing Markdown mirror
  • /sitetree.json valid JSON, path/redirect schema verified against /hardware/- and /try/-style manualLink sections, zero /tags/* leakage
  • /llms-full.txt verified byte-identical to the real .md mirror for its curated pages, zero HTML/shortcode leakage, zero escaping artifacts
  • /tutorials/catalog.md entry count (37) matches the existing, trusted typesense.json tutorials index; off-site canonical URLs (codelabs.viam.com, viam.com/post/*) resolve correctly
  • Size guardrail and missing-page guardrail (warnf) both tested firing and silent in the correct conditions
  • Live hugo server spot check: all four routes return 200 with correct content-types; <head> links present/absent exactly as designed (llms.txt/sitetree.json linked from every page; llms-full.txt and the tutorials catalog deliberately not — the tutorials catalog is linked only from llms.txt and its own two stub pages)
  • Simulated tutorials-section removal to confirm llms-full.txt's generation has no hidden dependency on it (list.markdown.md, its trigger point, is the generic Markdown-mirror template shared by ~90 sections, not tutorials-specific)

Known, accepted risks (documented in CLAUDE.md)

  • /tutorials/catalog.md depends entirely on docs/tutorials/_index.md's outputs: override existing. If /tutorials/ is ever removed from the site, this stops generating silently, and llms.txt's link to it needs removing in the same change.
  • No automatic CI assertion that these four files exist in the production build — deliberately out of scope here, flagged as a fast-follow.

🤖 Generated with Claude Code

…format

Prevents a repeat of the list.<format> collision found in the previous
round: any future .md-suffixed list.* template (e.g. an upcoming tutorials
catalog) needs every candidate sharing that suffix to be explicitly
name-qualified to its own format's Name, including this one, which was
previously left as a bare, unqualified fallback.
Ported from the previous round's cherry-picked recursion, but generated
via resources.Get | ExecuteAsTemplate instead of a custom output format --
avoids the list.<format> collision class entirely by construction. Schema
change: a node's path is present only when it has real content of its
own; a pure-signpost section carries redirect instead, so an agent can
tell 'real page, safe to fetch' from 'stub, follow redirect' by field
presence rather than comparing paths against children.

Found and fixed during verification: ExecuteAsTemplate's context must be
explicitly site.Home, not the ambient page context head.html happens to
be rendering when the Pipes resource first executes -- otherwise the
tree's root is whatever page triggers it first, not the home page.
…text template

Reuses render-page-markdown.html unchanged, same as every page's own .md
mirror. Real finding during verification: a Hugo Pipes resource's
shortcode-template resolution inherits whichever output format's render
pass is actually executing the resources.Get | ExecuteAsTemplate call --
not the resource's own naming, not the page context passed to it.
Triggering from head.html (always HTML) resolved shortcodes to their raw
HTML variants; triggering from within list.markdown.md (always MARKDOWN)
resolves them to the clean .md variants instead. Verified empirically,
including that nesting the trigger inside llms.txt's own template
(itself reached via head.html) does not help -- it inherits the same
HTML-rooted ambient context regardless of nesting depth.

No <head> link for this file, per design -- llms.txt's own body link is
its sole, properly-contextualized discovery path.
New TUTORIALCATALOG output format, scoped only to /tutorials/ via that
section's own outputs: frontmatter override (matching how sitetree.json/
llms.txt/llms-full.txt are scoped, just via Hugo Pipes instead). Reuses
list.typesense.json's existing filter and canonical-URL-fallback logic
verbatim -- verified matching entry count (37) against that trusted,
already-live index. Skips all of typesense.json's card-rendering fields
(images, video, wordcount, cost, dates), keeping only what's useful to an
agent: title, description, canonical-aware URL, and a compact facet line.

llms.txt gets a new Tutorials section linking it, and its sitetree.json
caveat now points here instead of dead-ending on 'not included.'

Known risk, loudly noted in the template's own header comment: this
file's existence depends entirely on docs/tutorials/_index.md's
outputs: override. If /tutorials/ is ever removed from the site, this
stops generating silently, and llms.txt's link to it 404s.
@netlify

netlify Bot commented Sep 11, 2026

Copy link
Copy Markdown

Deploy Preview for viam-docs ready!

Name Link
🔨 Latest commit 4fe00c8
🔍 Latest deploy log https://app.netlify.com/projects/viam-docs/deploys/6aa8218e6efdd90007d7caf8
😎 Deploy Preview https://deploy-preview-5312--viam-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 40 (🟢 up 3 from production)
Accessibility: 99 (no change from production)
Best Practices: 100 (no change from production)
SEO: 92 (no change from production)
PWA: 60 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@viambot viambot added the safe to build This pull request is marked safe to build from a trusted zone label Sep 11, 2026
…y-files.html

CLAUDE.md's Agent discoverability files section is now the single place
these explanations live; the code just points at it instead of
duplicating the full story.
Both stub pages' HTML layouts (tutorials.html, tutorials-all.html) never
reference .Content, so adding this pointer has no visible effect on the
human-facing pages -- confirmed by grep. Real side effect found and
fixed: schema.html's JSON-LD description uses .Summary (auto-derived
from body content), not the frontmatter description -- adding body text
overwrote it with the pointer instead of the real topical description.
Added explicit summary: frontmatter matching description: on both pages
to keep that metadata correct.

Copy link
Copy Markdown
Collaborator

Reviewed 92ff9be: two clean prod builds on the CI-pinned Hugo 0.152.2, diffed against a baseline build of c4c03b3, plus targeted experiments in throwaway sites. The redesign does what it claims — findings below are what's left, not an argument against the approach.

Confirmed working. Only 2 of 1282 generated .md files differ from baseline (the two tutorials stubs, intentional) — all 90 section mirrors byte-identical, so #5311's collision class really is gone. _redirects is unchanged, so the Netlify rewrite is genuinely retired. Zero escaping artifacts and zero raw HTML in all four new files; llms-full.txt is byte-identical to #5311's verified-clean output. sitetree.json: 390 path + 25 redirect, no node with both or neither, zero duplicate paths, every redirect target resolving to a real node. Catalog: 37 entries, matching typesense.json exactly. Two clean builds produce byte-identical output for all four files, so the site.Home context fix holds. vale/prettier/markdownlint clean (the two docs/tutorials/*_index.md now fall inside prettier and markdownlint scope — checked specifically). The summary: additions are necessary, not incidental: without them .Summary becomes the catalog sentence in schema.html's JSON-LD.

1. llms.txt sends agents to Markdown stubs for half its reference and guide links

sitetree.json gets a path/redirect schema precisely so a consumer "can't accidentally fetch an empty stub." llms.txt — the actual entry point, the file an agent reads first — gets no such treatment. Six of its eleven link-only targets have a .md mirror that is one line of "This page redirects to…":

path HTML .md mirror
/build-apps/ 70,849 B real page redirect stub
/build-modules/ 70,965 B real page redirect stub
/set-up-a-machine/ 71,048 B real page redirect stub
/reference/apis/components/ 235 B meta-refresh redirect stub
/reference/apis/services/ 233 B meta-refresh redirect stub
/reference/components/ 260 B meta-refresh redirect stub

The first three are the sharp ones: rich in a browser, empty for the Markdown consumer llms.txt is written for, in the same file that tells agents to append .md to any URL.

These paths are pre-existing — identical in static/llms.txt on main — so this isn't a regression. But this PR is the one that turns the list into data/llms_pages.yaml, and render-page-markdown.html already computes exactly the "is this a stub" signal via resolve-chain.html + .Params.canonical. Either point the YAML at the resolved targets, or resolve at generation time.

The same gap makes the sitetree schema's stated contract not quite true: $isRedirect keys only on manualLink/manualLinkRelref, so 12 nodes carry path while their mirror is a stub — the five canonical SDK pages, six empty_node sections, and the tree root / itself. / is harmless in practice; it's just the cleanest demonstration that field presence doesn't mean what the docs say it means.

2. single.md — and the rule I gave you on #5311 was wrong

I told you on #5311 to name-qualify every list.*/single.* template. That advice was imprecise, and acting on it naively breaks the site. Both corrections are mine to make.

The real rule is lexical sort order. A list.<fmt>.md / single.<fmt>.md shadows the unqualified template if and only if <fmt> sorts before md. Verified across controlled builds: llmsfulltxt, acat → shadowed; tutorialcatalog, zcat → clean. So #5311 broke because l < m, and #5312 is safe because t > m — not because TUTORIALCATALOG is scoped through frontmatter. Format assignment is irrelevant to the lookup.

And the obvious fix has a trap. Renaming single.mdsingle.markdown.md on its own silently guts /reference/glossary.md: 466 lines → 6, every term body gone, and it's the only file that changes so nothing else signals it. A qualified _default/single.markdown.md outranks the unqualified layout-specific layouts/docs/glossary.md. Renaming that to glossary.markdown.md in the same change restores byte-identical output.

Leaving single.md unqualified is defensible — nothing currently threatens it. What isn't defensible is leaving the wrong rule in CLAUDE.md for whoever adds the next .md output format.

3. The documented llms-full.txt publishing trigger doesn't exist

The PR body and CLAUDE.md's "Two more Pipes gotchas" both say head.html carries {{- $_ := $agentFiles.llmsFullTXT.RelPermalink -}} to force publishing. head.html:59 is a comment saying the opposite ("deliberately not linked or triggered here"), and agent-discoverability-files.html returns a dict with only sitetreeJSON and llmsTXT — the documented expression would fail outright. The real trigger is layouts/_default/list.markdown.md:20.

For a mechanism whose documented failure mode is "remove this and the file silently stops generating, no build error," both docs pointing at the wrong file is the expensive kind of wrong.

4. The Pipes files never regenerate under hugo server

Tested directly: started hugo server --disableFastRender, edited a section title in data/llms_pages.yaml, watched the server log Data changed /llms_pages.yaml and rebuild — and /llms.txt still served the old title. Same execute-once cache the site.Home gotcha describes. Production is unaffected, but the "live hugo server spot check" in the test plan only holds for the initial build, and CLAUDE.md's step 5 will mislead anyone iterating on the YAML. Worth a line next to the other Pipes gotchas: only editing the asset template itself busts the cache.

Nits

  • CLAUDE.md lines 71, 75, 76 still reference layouts/_default/list.md, which this PR renames away.
  • assets/llms.txt:18 still keys the sitetree link on eq .title "Reference" — renaming that YAML section silently drops the link. Raised on Generate sitetree.json, llms.txt, and llms-full.txt at build time #5311, still open.
  • llms.txt's link text changed against production, not just against Generate sitetree.json, llms.txt, and llms-full.txt at build time #5311: the hand-written agent-oriented blurbs are now page .Description SEO text, and some titles moved ("Viam CLI reference" → "CLI reference"). Deliberate?
  • Three rel="describedby" links in every page's head now; a tutorials catalog isn't really a description of /reference/components/motor/. Taste, take it or leave it.

Not a finding, for the record: I measured 54.6s vs a 41.9s baseline and nearly reported a build-time regression. A second build came in at 41.2s. Noise — there's no performance cost here.


Generated by Claude Code

@jeremyrose-viam

Jeremy Rose (jeremyrose-viam) commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

Pushed 6 commits addressing the review.

Finding 1 (llms.txt stub-mirror links): fixed for /reference/apis/components/, /reference/apis/services/, /build-apps/, /build-modules/, and /set-up-a-machine/ — each now curated at its real destination page instead of a stub. The two API pages also had a real site bug underneath: their canonical pointed at a shared generic overview instead of their own (already-existing) overview pages — fixed at the source, not just papered over in data/llms_pages.yaml.

/reference/components/ is a different case: it has no overview page to point at all (unlike its /reference/services/ sibling, which has a real landing page). Filed DOCS-4972 to build one; left the curated entry as-is with a comment pointing at the ticket rather than faking a fix.

sitetree.json's 12-node $isRedirect gap is documented as a known limitation in CLAUDE.md, not fixed — expanding the recursion into a general redirect-chain resolver changes what that file is (a plain reflection of the page graph) for a schema completeness gain that didn't seem worth it.

Finding 2 (collision rule): corrected — the real mechanism is lexical sort order (list.<fmt>.md shadows list.md iff <fmt> sorts before md), verified empirically against a fresh test case, not just trusted. Also documented the single.md/glossary.md rename trap you flagged.

Finding 3 (llms-full.txt trigger docs): fixed — CLAUDE.md now correctly points at list.markdown.md, not head.html.

Finding 4 (hugo server caching): documented as a Pipes gotcha.

Nits: stale list.md references in CLAUDE.md fixed; sitetree_link flag replaces the hardcoded eq .title "Reference" check; describedby links trimmed from 3 to 2 (tutorials catalog now only referenced from llms.txt and its own two stub pages, not every page's head).

Link text vs. production — deliberate, and more than that: production's hand-written llms.txt predates this whole system by about a week and had no real design behind it. It's not a baseline worth preserving. The generated version is judged against what actually helps the audience it's for, not against what got scrambled together before this system existed.

Also simplified llms.txt itself: curated paths now link exactly what's in data/llms_pages.yaml, with no automatic redirect-following or substitution. If a curated entry turns out to be a thin stub, that's a curation problem to fix at the source — visible and correctable, not something the template quietly routes around.

@jeremyrose-viam

Copy link
Copy Markdown
Member Author

One more commit (f2cb6cc): added an optional per-section description field to data/llms_pages.yaml, rendered verbatim (Markdown and all -- this is Go's text/template, no escaping) between a section's heading and its link list. Used it once, on "Reference," to disambiguate the two reference systems that live there (API methods vs. JSON config) -- not applied elsewhere since most sections don't have that kind of ambiguity to resolve.

@btshrewsbury-viam

Copy link
Copy Markdown
Collaborator

Re-reviewed at f2cb6cc (prod builds of head vs main on Hugo 0.152.2, independently re-verified). The fixes land: 5 of the 6 stub links now resolve to real content, only the two tutorials .md files differ from main, _redirects is unchanged, and sitetree.json's 12 stub nodes match the documented limitation. Nothing blocking. A few corrections:

  1. The API canonical edits don't reach anything. netlify.toml:196-206 force-301s /reference/apis/{components,services}/ to /reference/apis/, and that still wins on the deploy preview. render-page-markdown.html resolves Netlify redirects before canonical, so both .md mirrors are byte-identical to main and still say "redirects to APIs". The only file that changed is a never-served HTML meta-refresh. The llms.txt fix comes entirely from the YAML repoint. To fix it at the source, change the netlify.toml targets.

  2. The /reference/components/ comment names the wrong target. It says the page redirects to /reference/components/arm/, but that's only the frontmatter canonical. netlify.toml:209 force-301s it to /hardware/common-components/, which is also what its .md mirror says. A new overview page for DOCS-4972 would be shadowed the same way unless that redirect changes. The new Reference description also uses "Built-in components", the one remaining stub, as its configuration-reference example.

  3. The CLAUDE.md collision rule is right, but the reason given is wrong. "markdown sorts after md" is false: byte-wise, markdown < md. Tested builds:

    • With an unqualified list.md, a format named mc shadows all 90 section mirrors and me shadows none, so the rule holds.
    • With list.markdown.md, formats named acat and ma shadow nothing.
    • list.markdown.md is safe because a template qualified with its own format name outranks one qualified with another format's name, not because of sort order.

Nits:

  • The PR description still says head.html triggers llms-full.txt and that the tutorials catalog is linked from every page.
  • llms.txt says toc_hide pages are covered by the tutorials catalog, but reference/services/generic/fake is in neither.

@jeremyrose-viam

Copy link
Copy Markdown
Member Author

Pushed 4 commits addressing this review.

1. canonical edits don't reach anything — fixed at the actual source: netlify.toml's forced redirects for /reference/apis/components/ and /reference/apis/services/ now point at their real overview pages instead of the generic one. Verified both .md mirrors reflect the new target. These two lines were simply stragglers — six sibling redirects in the same file already follow this exact pattern (layout: "empty" + canonical stub upgraded to a real 301, "for SEO and UX" per that block's own comment); these two were added a few months before the overview pages they should point to existed, and never got updated once those pages shipped.

2. /reference/components/ comment names the wrong target, DOCS-4972 would be shadowed — the comment now correctly cites /hardware/common-components/ (the real netlify.toml target) instead of the frontmatter-only canonical value. DOCS-4972 updated with an explicit note that a new overview page alone won't be enough — the netlify.toml redirect needs updating too, or it'll shadow the new page the same way. The Reference section's description no longer cites "Built-in components" (the one entry that's still actually broken, out of scope for this PR) as its configuration-reference example — swapped to "Services reference," which works.

3. Collision rule reasoning is wrong — corrected. Independently re-verified rather than taken on faith: built list.markdown.md alongside a new throwaway list.acat.md, confirmed the existing qualified template is untouched regardless of the new format's name or where it sorts. CLAUDE.md now states the real mechanism (exact format-name match wins) instead of the sort-order explanation, which was backwards ("markdown" does sort before "md", not after — my error).

Nits: PR description corrected (no longer claims head.html triggers llms-full.txt or that the tutorials catalog is linked from every page). llms.txt's toc_hide line no longer implies the tutorials catalog covers everything hidden from nav -- it now says plainly that anything else hidden from navigation isn't intended to be part of the site's structure, full stop, rather than gesturing at a specific example (which risked sending an agent looking for something deliberately unlisted).

Thanks for the thorough re-review -- the netlify.toml finding in particular was a real miss on my part (I'd fixed the Hugo-side canonical and treated that as the fix, without realizing a completely separate config file was what actually governed live routing).

@viambot

viambot commented Sep 14, 2026

Copy link
Copy Markdown
Member

It looks like the following files may have been renamed. Please ensure you set all needed aliases:

 rename layouts/{_default/list.md => partials/render-page-markdown.html} (56%)

@btshrewsbury-viam

Copy link
Copy Markdown
Collaborator

Re-reviewed at 4fe00c8. Everything from my last pass is addressed, and I found nothing new.

  • The netlify.toml redirects for /reference/apis/{components,services}/ now 301 to their overview pages on the deploy preview. Both .md mirrors follow, and there's no loop.
  • The /reference/components/ comment, the Reference description example, the collision-rule wording, the toc_hide line, and the PR description are all corrected.
  • Built the previous and new heads and diffed them: only llms.txt (the two intended lines) and the two API .md mirrors changed, and _redirects is identical.
  • Merges cleanly with current main.

@jeremyrose-viam
Jeremy Rose (jeremyrose-viam) merged commit d14d6df into viamrobotics:main Sep 14, 2026
13 checks passed
@github-actions

Copy link
Copy Markdown

🔎💬 Inkeep AI search and chat service is syncing content for source 'Viam Docs'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

safe to build This pull request is marked safe to build from a trusted zone

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants