Skip to content

feat(docs): add sitemap, llms.txt, robots and per-page share metadata - #787

Open
Decipher wants to merge 4 commits into
feature/docs-site-redesignfrom
feature/docs-seo
Open

feat(docs): add sitemap, llms.txt, robots and per-page share metadata#787
Decipher wants to merge 4 commits into
feature/docs-site-redesignfrom
feature/docs-seo

Conversation

@Decipher

@Decipher Decipher commented Aug 29, 2026

Copy link
Copy Markdown
Member

Stack

Merge from the bottom up. Each PR is based on the one below it, so the diff of
each shows only its own work.

  1. docs: redesign the docs/nuxt site shell, homepage, search and module pages #786 docs site redesign -> develop
  2. feat(docs): add sitemap, llms.txt, robots and per-page share metadata #787 SEO layer (this PR) -> #786

Based on feature/docs-site-redesign, so #786 must merge first. GitHub will
retarget this PR to develop automatically when it does.

Stacked on #786. The diff shown here is only the SEO work; merge that one first.

The docs site had no sitemap, no robots.txt and no canonical URLs, and a single set of Open Graph tags shared by all 130 routes. Every page presented the homepage's title, description and share image.

What changed

Gap Before After
sitemap.xml none 130 URLs, valid XML, per-URL lastmod, priority, changefreq
llms.txt none 6.4 kB index in the llmstxt.org format
robots.txt none allows all, blocks build artefacts, points at the sitemap
Canonical URLs none every content page
Open Graph / Twitter one set for all 130 routes per page, each tag exactly once
Meta descriptions empty site-wide derived from the first line of prose

Four commits, in dependency order: per-page metadata, then the generated indexes, then tests, then the CI audit.

The part worth reviewing closely

59 of 109 API pages were never being generated.

Nuxt discovers dynamic routes by crawling links out of pages it has already generated. AppApiIndex fetches its entries client side, so most of those links do not exist in the generated HTML for the crawler to follow. Those 59 pages existed only as the SPA fallback: served by 200.html, invisible to a crawler, and impossible to list in a sitemap honestly. generate.routes now names every content route.

Before After
API pages written to dist 50 116
Sitemap URLs with no page behind them 61 0

This is a pre-existing bug. The sitemap only made it visible, by listing URLs that turned out to 404.

Decisions worth flagging

Content is read from disk, not through @nuxt/content. Its v1 package exports only Database, and instantiating one starts a file watcher that never releases the event loop, so a generate using it never exits.

Frontmatter is parsed directly rather than with a library. content/ uses three single-line scalars. The docs site is not a yarn workspace of the root, so a dependency it declares is missing from the root tooling that also reads these files: adding one broke test:unit and lint:knip, and relocating it to the root invalidated the CI cache and timed out six jobs. The parser is verified byte-identical to gray-matter across all 129 content files, and ignores anything it does not recognise rather than guessing.

llms.txt does not list all 109 API pages. The guide and module pages are listed in full; the ten package API indexes go under ## Optional. Listing every generated page buries what the project is under near-identical entries.

Links in llms.txt carry UTM params. The convention is not settled, and tagging is the only way to learn whether the file earns its place. Per-page rel=canonical means the extra params cost nothing in search.

Two modules were competing for the same tags. nuxt-social-meta is removed: its tags were hid-keyed and so were being replaced rather than duplicated, but it was a second source of truth for the share title and image. Removing it exposed a third source. @nuxtjs/pwa also emits og:* and twitter:* built from package.json, so og:title read druxtjs-org, the npm package name, and the same leak reached apple-mobile-web-app-title and the web manifest. Measured: 137 pages carried the package name, now none.

The error layout exists for its noindex. Seven files are generated for intermediate API directory paths that have no document, and the breadcrumbs on their own child pages link to them, so a crawler reaches a "Document not found" page by following ordinary links.

Tests

79 tests over the four pure modules, covering README and index collapsing, the generated-HTML short circuit when excerpting, frontmatter edge cases, XML escaping, the description clamp either side of its limit, and the exact tag set seoHead returns. The suite goes from 72 files and 257 tests to 76 and 336.

This also fixes a jest config bug it turned up, unrelated to the SEO work and latent on develop: testPathIgnorePatterns and modulePathIgnorePatterns matched the bare string /docs against the whole absolute path, so any checkout whose own directory contained "docs" excluded the entire repository. Both are anchored to <rootDir> now.

Lighthouse audit

Adds an unlighthouse config and two CI jobs. They are split because the halves cannot share a Node version: the site is pinned to Node 16 and unlighthouse needs 18+. The audit only speaks HTTP to the built site, so it runs on its own image and never imports project code, leaving the toolchain freeze intact. Note this is wired into the GitLab pipeline; the equivalent GitHub Actions job is not included here.

Baseline over all 131 routes:

Category Min Avg Pages below 90
SEO 100 100 0
Best practices 96 100 0
Accessibility 91 96 0
Performance 75 97 3

ci.budget is 0 for now. The worst page scores 0.93 overall, so 85 is a sensible value to set once someone is happy for this to gate a merge.

Routes come from dist/sitemap.xml with the origin stripped, so audit coverage and sitemap coverage are the same set by construction. Pointing the scanner at that file directly does not work: unlighthouse discards sitemap entries from a different origin than the site it is auditing, which it always will here, and falls back to crawling links. That took three attempts, each reporting success while auditing less than it claimed, so the job now asserts the sitemap exists and advertises at least 100 routes.

Known failures this surfaced, not fixed here

Both are colour decisions rather than mechanical fixes, so they are left for a separate change:

  • color-contrast on 120 pages: badge-primary badge-outline renders #53b1eb on white at 2.37:1, against the 4.5:1 needed for small text.
  • color-contrast on 22 of those: Prism's token comment at 2.43:1, from the upstream material-oceanic theme.

Follow-ups filed separately

Two content problems this work depends on but does not fix, both tracked on the internal tracker rather than here:

  • modules/entity/deprecations.md has no frontmatter, so its title falls back to the package name and its navigation link renders with no accessible text. The generators fall back to a route-derived title, but the page itself still needs the fix.
  • docgen titles each package's API index after the first symbol it documents, so /api/packages/site is titled DruxtSiteMixin. Worked around here by labelling llms.txt entries from the route.

Verification

Against a real nuxt generate, not the config:

  • 130 content pages, each of the 12 share tags appearing exactly once on every one
  • 0 missing canonicals, 7 error pages carrying noindex, 0 pages leaking the package name
  • sitemap.xml parses as XML, correct origins, no stray trailing slashes, every URL resolving to a generated file
  • /llms.txt, /sitemap.xml, /robots.txt all return 200 from the served build
  • eslint 0 errors, cspell 0, markdownlint 0, knip 0

All 130 routes shared one set of Open Graph tags, set site-wide in
nuxt.config.js, so every page presented the homepage's title, description
and share image. None declared a canonical URL, leaving the trailing-slash
and query-tagged variants of a URL as separate indexable pages.

seoHead builds the full set per page and is now the only place any of it is
decided. Every tag is keyed by hid so a page or layout can override one by
name without emitting a second copy.

Descriptions are derived from the first line of prose, because almost
nothing in content/ sets one in frontmatter and @nuxt/content only surfaces
frontmatter fields. Generated API pages are detected by structure and
skipped: they are jsdoc output, and excerpting them yields fragments torn
out of the middle of a tag. Those fall back to a description of their
section, which beats repeating the site-wide blurb on 98 pages.

Two modules were competing for the same tags and are no longer:

- nuxt-social-meta is removed. Its tags were hid-keyed and so were being
  replaced rather than duplicated, but it stayed a second source of truth
  for the share title and image. What it uniquely supplied has moved into
  seoHead: og:image:width/height, which several scrapers want before they
  will render a card, and the Twitter handle.
- @nuxtjs/pwa also emitted og:* and twitter:* built from package.json, so
  og:title read "druxtjs-org", the npm package name. It was invisible
  underneath nuxt-social-meta. The same leak reached
  apple-mobile-web-app-title and the web manifest, where a share-tag audit
  would not look. Its share tags are off and its name and description come
  from lib/site.js. Measured: 137 pages carried the package name, now none.

Adds an error layout, whose purpose is its noindex. Seven files are
generated for intermediate API directory paths that have no document, and
the breadcrumbs on their own child pages link to them, so a crawler reaches
a "Document not found" page by following ordinary links.

Verified against a full generate: 130 content pages, each of the 12 share
tags appearing exactly once on every one, and no page missing a canonical.
The site had none of the three. Every URL below the homepage was
discoverable only by crawling links, and the generated API reference is the
part of the site least likely to be linked from anywhere external.

Written at generate:done, from the content tree rather than through
@nuxt/content: its v1 package exports only Database, and instantiating one
starts a file watcher that never releases the event loop, so a generate
using it never exits. Frontmatter is parsed directly. content/ uses three
single-line scalars, which is not worth a dependency, and the docs site is
not a yarn workspace of the root, so anything it declares is absent from the
root tooling that also reads these files.

llms.txt lists the guide and module pages in full and the ten package API
indexes under `## Optional`. Listing all 109 generated API pages buries what
the project is under near-identical entries. Links carry UTM params, which
is the only way to find out whether the file earns its place rather than
guessing later; per-page canonicals mean the extra params cost nothing in
search. Package entries are labelled from the route, because docgen titles
each package index after the first symbol it documents.

robots.txt allows everything except the build artefacts and points at the
sitemap. Assistants are deliberately not blocked.

Also fixes 59 API pages that were never generated. Nuxt discovers dynamic
routes by crawling links, and the API index fetches its entries client
side, so those links do not exist in the HTML for the crawler to follow.
They existed only as the SPA fallback: unreachable by any crawler and
impossible to put in a sitemap honestly. generate.routes now names every
content route. Measured 50 of 109 API pages generated before, 116 after,
and sitemap coverage is 130 of 130 with no URL lacking a page.
Every one of these modules documented a test seam it did not use, and the
contracts were enforced only by running a full generate: a change to
routeFor, excerpt or the description clamp would have corrupted sitemap.xml,
llms.txt and every page's meta at once with nothing failing.

79 tests over the cases most worth pinning: README and index collapsing,
the generated-HTML short circuit in excerpt, the TODO skip, frontmatter
parsing including colons in values and quoted forms, XML escaping,
clampDescription either side of the limit including the single-long-word
branch, and the exact hid set seoHead returns.

Fixes a jest config bug found while writing them. testPathIgnorePatterns
and modulePathIgnorePatterns matched the bare string '/docs' against the
whole absolute path, so any checkout whose own directory contained "docs"
excluded the entire repository: in a worktree at .worktrees/docs-seo, jest
found 0 of 72 test files and exited 1. Both are anchored to <rootDir> now
and name the directories actually worth skipping.

Suite goes from 72 files and 257 tests to 76 and 336.
@changeset-bot

changeset-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: b121787

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 027dea8f-6620-4138-8814-45360be41f6d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.49%. Comparing base (3c009c0) to head (b121787).

Additional details and impacted files

Impacted file tree graph

@@                     Coverage Diff                     @@
##           feature/docs-site-redesign     #787   +/-   ##
===========================================================
  Coverage                       81.49%   81.49%           
===========================================================
  Files                             112      112           
  Lines                            2853     2853           
  Branches                          616      576   -40     
===========================================================
  Hits                             2325     2325           
  Misses                            436      436           
  Partials                           92       92           
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Adds an unlighthouse config and a Lighthouse audit to both pipelines.

GitLab needs two jobs, because the halves cannot share a Node version: the
site is pinned to Node 16 and unlighthouse needs 18+. GitHub Actions needs
only one, because setup-node can switch versions mid-job. Either way the
audit only speaks HTTP to the built site and never imports project code, so
it runs on its own Node and the toolchain freeze is untouched.

The GitHub job reuses the build job's artifact rather than rebuilding, since
build:docs runs packages/docgen/bin/druxt-docgen.js, which is itself a build
output. Nothing had downloaded that artifact before, so its layout was never
exercised, and the job checks content/api arrived rather than trusting it.
Without it the site still generates, just with 21 routes instead of 131.

Routes come from dist/sitemap.xml with the origin stripped, so audit
coverage and sitemap coverage are the same set by construction. Pointing the
scanner at that file directly does not work: unlighthouse discards sitemap
entries whose origin differs from the site being audited, which it always
will, since the audit serves the build on localhost. It says so and falls
back to crawling links, which quietly audits only what happens to be linked.

Getting that right took three attempts, each of which reported success while
auditing less than it claimed: 47 routes crawled, then 4 when the route list
went unread and the crawler was disabled alongside it, then 69 in CI against
129 locally when the list was read from content/, which that job does not
receive. The crawler is deliberately left enabled beneath the supplied list
as a floor, and both jobs assert Playwright's Chromium was found,
dist/sitemap.xml exists, and the sitemap advertises at least 100 routes, so
a degraded run fails loudly rather than passing.

Versions are pinned rather than floating on @latest. This toolchain is
version-sensitive in ways that bite silently, and a config key that a future
release stops reading would change an audit on a re-run.

ci.budget is 0 for now. The first green run gives the numbers to set it
from: 131 routes, worst page 0.93 overall, SEO 100 across the board.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant