This is the dbatools.io website — a Hugo static site with Tailwind CSS.
The /commands page and all individual /{CommandName} pages are built from
data pulled out of the dbatools PowerShell module's GitHub repo. The pipeline runs automatically via .github/workflows/deploy.yml in this same repo
(dataplat/web) on every push to main or html, and can be re-run locally at any time.
dataplat/docs— the legacydocs.dbatools.iosite. Separate repo, separate CI (update-assets.yml, triggered byrepository_dispatch). Generates raw.htmlfiles viaassets/templates/to_md.ps1. Nothing indataplat/webreads from it.dataplat/dataplat.github.io— stores raw data assets (builds ref, dbatools-index) pulled daily from the dbatoolsdevelopmentbranch. Feeds the/buildspage, not the commands pipeline. The commands pipeline fetches directly fromdataplat/dbatoolsmasterbranch at build time — it does not use the cached copy here.
https://raw.githubusercontent.com/dataplat/dbatools/master/bin/dbatools-index.json
(also used from the development branch by generate-command-pages.ps1)
-
scripts/transform-dbatools-index.ps1- Downloads
dbatools-index.json, transforms it intostatic/commands.json - Derives category, verb, url, popular, popularityRank fields
- fullContent is left empty at this stage
- Downloads
-
scripts/generate-command-pages.ps1- Downloads same index, generates
content/commands/*.md(one per command) - YAML front matter includes: title, slug, author, availability (e.g. "Windows, Linux, macOS"), synopsis, tags, sourceUrl
- Content: synopsis, description, syntax, examples, parameter tables
- Downloads same index, generates
-
scripts/enrich-commands-json.ps1- Reads each .md file, strips HTML/markdown, writes plain text into
fullContentinstatic/commands.jsonfor Fuse.js search
- Reads each .md file, strips HTML/markdown, writes plain text into
-
scripts/update-popular-commands.ps1- Reads
rankings.txt(page-view analytics data) - Sets
popularityRankon all commands; marks top 50 aspopular: true
- Reads
-
scripts/update-commands-urls.ps1- Normalises all
urlvalues to/{CommandName}format
- Normalises all
Do not trust the Availability field in dbatools-index.json. It says
"Windows, Linux, macOS" for all 718 commands, so it carries no information.
The cause is upstream. dbatools.psm1 defines $script:xplat,
$script:windowsonly and $script:noncoresmo inside an
if ($PSVersionTable.PSVersion.Major -lt 5) block, so on PowerShell 5.1 and 7.x
they are all $null. Get-DbaHelp then tests $Name -in $script:windowsonly
against $null, which is always false, and every command falls through to the
cross-platform default that gets baked into the index at release time. This has
been the case since dbatools 2.0 (commit 0eb72452, Nov 2022) and affects the
legacy docs.dbatools.io site too.
So the website derives it instead. scripts/lib-platform-lists.ps1 fetches
dbatools.psm1 and parses $script:windowsonly (128) + $script:noncoresmo
(31) = 159 Windows-only commands, and throws if the parse returns under 100 so a
regex break can't silently mark everything cross-platform. Both
transform-dbatools-index.ps1 and generate-command-pages.ps1 use it.
Where it surfaces:
content/commands/*.md—availability:front matter, plus the badge HTML thatgenerate-command-pages.ps1writes into the page body (the badge is in the generated markdown, not inlayouts/commands/single.html)static/commands.json—availability(display string) andwindowsOnly(boolean), used by the Platform filter and card badge on /commands
Once the upstream psm1 fix ships, the index's own Availability becomes
trustworthy and lib-platform-lists.ps1 can be retired.
Research and evidence: research/optimizing-docs-sites-for-llms-2026-07-31.md.
Read that before changing anything below — it records which claims come from
Google/Microsoft/OpenAI/Anthropic directly and which are contested.
The one load-bearing fact: OpenAI's, Anthropic's and Perplexity's crawlers fetch JavaScript but do not execute it. Anything rendered client-side is invisible to them. Google and Apple render; nobody else does. So any list or navigation that matters must exist in the HTML at build time.
What the site emits:
/commands/— the card grid and all three sidebar filter lists are rendered by Hugo fromstatic/commands.jsonat build time (layouts/page/commands.html+partials/command-card.html).commands-page.jsthen replaces the same markup for search and filtering. Ifcommands.jsonfails to load, the JS bails out and leaves the static list.partials/command-card.htmlmust stay in sync withcreateCommandCard()instatic/js/commands-page.js./<page>/index.md— markdown mirror of every page, via theMDoutput format. Command pages uselayouts/commands/single.md, which strips the header card, the on-this-page nav and the anchor-wrapped headings out of the generated body. Ifgenerate-command-pages.ps1changes the shape of that body, the three regexes in that template are what breaks./commands/index.md— the full command index as markdown (layouts/page/commands.md), becausecontent/page/cmd.mdhas no body./llms.txt—layouts/index.llms.txt. Cheap hedge, not a strategy; no answer engine documents using it./robots.txt—layouts/robots.txt. Names each AI crawler explicitly and carries theSitemap:line. Per RFC 9309 a bot uses only its own group and ignores*, so each named bot needs its ownAllow.- JSON-LD —
partials/structured-data.html.APIReferenceon command pages (withtargetPlatformfrom the derived Windows-only list),TechArticleon posts,WebSite+SoftwareApplicationon the home page, plusBreadcrumbList. Do not addHowToorFAQPage: Google removed HowTo rich results and restricted FAQ to government and health sites in 2023. <link rel="canonical">and<link rel="alternate" type="text/markdown">in_default/baseof.html.
Examples on command pages are h3.example-heading with the example's own
description in the heading text, so each one is a passage that can be retrieved
and quoted alone. That is deliberate — Microsoft's guidance is that AI search
retrieves passages, not pages.
Not done, and not doable from this repo: GitHub Pages cannot emit custom
response headers, so Accept: text/markdown content negotiation and
Link: rel="canonical" headers are out unless routed through Cloudflare.
scripts/lib-platform-lists.ps1— shared Windows-only command list parserstatic/commands.json— the JSON database consumed by the browser JSstatic/js/commands-page.js— Fuse.js search + filter logic for /commandsthemes/dbatools2025/layouts/page/commands.html— /commands listing templatethemes/dbatools2025/layouts/commands/single.html— individual command templatestatic/css/commands-page.css— styles for listing pagestatic/css/command-single.css— sidebar/layout for individual pagesstatic/css/command-docs.css— article content styling for individual pagesrankings.txt— popularity data (do not overwrite without new analytics)scripts/README.md— detailed pipeline documentationCOMMAND_PAGES_README.md— layout file reference and "do not create" warnings
- Do NOT create
layouts/page/command-detail.htmlorstatic/css/command-detail.css— these are not used and cause confusion. - Do NOT edit
static/commands.jsondirectly; re-run the pipeline scripts. - Do NOT make
/commands/client-rendered again. That page has the site's highest impression count and the lowest generative-AI pickup of any top page (see the research note); the static grid is the fix. - The commands layout is
layouts/commands/single.html, NOTlayouts/_default/single.html— Hugo picks it up automatically because content is incontent/commands/.
GA4 property G-WZ9LF10DRD, loaded in _default/baseof.html. Two search
surfaces report to it, and both are hand-written — GA4 Enhanced Measurement
does not track search on this site and must not be re-enabled.
Both static/js/site-search.js (modal, Ctrl+K) and
static/js/advanced-search.js (/search/ page) send:
search—{ search_term }, fromreportSearch(). Fires 1200ms after typing stops, not on the 150ms render debounce, so the report holds questions rather than keystrokes. Deduped againstlastReported, including backspacing over a term already sent.search_result_click—{ search_term, link_url, result_position }, fromreportResultClick().search_termislastQuery(the query that produced the visible results, not what is in the box at click time);result_positionis 1-based, read from thedata-search-positionattribute written indisplayResults().
search_result_click is the whole point of the pair: search says what people
asked, search_result_click says whether they found it. Terms with search
events and no matching search_result_click are the content gaps.
- Site search — it cannot see the Ctrl+K modal, which has no URL. Leaving
it on meant two event names for one user action across two surfaces, plus
double-counting on
/search/, whereupdateUrl()writes?q=andqis a default GA4 site-search parameter. - Page views → "Page changes based on browser history events" — the site
has no
pushStateanywhere and only tworeplaceStatecallers (advanced-search.jsupdateUrl(),commands-page.jsfilter state). Neither is a navigation, so this only manufactured fake pageviews on the two busiest interactive pages.
Both changes produce a discontinuity dated 2026-07-31: view_search_results
stops accruing, and /search/ + /commands pageviews step down.
All Event scope. GA4 keys custom dimensions by parameter name across all events, so every report must break down by Event name or these blend.
| Dimension | Parameter | Also carried by |
|---|---|---|
| Site search term | search_term |
search and search_result_click |
| Clicked link URL | link_url |
Enhanced Measurement outbound clicks (still on) |
| Search result position | result_position |
nothing else |
Registration is not retroactive and takes 24–48h to populate. result_position
is a dimension, not a custom metric.
- Never intercept the click. No
preventDefault, no waiting on anevent_callback. gtag.js transports withnavigator.sendBeaconand flushes onpagehide(verified against the live bundle), so the hit survives unload on its own. A blocked tag must never break navigation. - Every reporting path is guarded by
typeof gtag !== 'function'. - Result clicks use one delegated listener on the container, bound in
setupEventListeners().displayResults()replacesinnerHTMLon every keystroke, so per-anchor listeners are destroyed immediately. - Bind both
clickandauxclick. Middle-click opens a new tab but firesauxclick, notclick; filterauxclickonbutton === 1so right-click context menus are not counted. checkUrlQuery()callsreportSearch()so shared and bookmarked?q=links still count. Enhanced Measurement used to cover that case.- Renaming a parameter means burning a custom dimension slot — they can be archived but never deleted, and there are 50.