Fix canonical URL and sitemap trailing slash consistency

This commit is contained in:
Djeex
2026-09-02 20:18:23 +02:00
parent 0b429f5465
commit 940096b2b1
4 changed files with 154 additions and 0 deletions
+15
View File
@@ -15,6 +15,21 @@ This project starts from the `docus` i18n starter template (`extends: ['docus']`
- **Markdown highlight.** Forces the `github-dark` Shiki theme for *both* the light and dark slots, because the site never actually offers a light mode (see `docus.colorMode: 'dark'` below) — maintaining two highlight themes for a mode nobody sees would just be dead config. The extra languages (`nginx, properties, php, toml, console, sh, yaml`) were added because the tutorial content includes config-file snippets and terminal output in all of these syntaxes, and none of them are in Shiki's minimal default bundle for Nuxt Content. - **Markdown highlight.** Forces the `github-dark` Shiki theme for *both* the light and dark slots, because the site never actually offers a light mode (see `docus.colorMode: 'dark'` below) — maintaining two highlight themes for a mode nobody sees would just be dead config. The extra languages (`nginx, properties, php, toml, console, sh, yaml`) were added because the tutorial content includes config-file snippets and terminal output in all of these syntaxes, and none of them are in Shiki's minimal default bundle for Nuxt Content.
- **`darkreader-lock` meta tag.** The Dark Reader browser extension rewrites elements' inline `style` attributes on the client, after Nuxt has already server-rendered them — so any component using an inline `style` (like the cyan "·" separator spans) ends up with mismatched HTML between server and client, and Vue logs a hydration-mismatch warning on every page load for any visitor running that extension. This meta tag is Dark Reader's own opt-out signal, telling the extension to leave the page alone instead of trying to work around the mismatch after the fact. - **`darkreader-lock` meta tag.** The Dark Reader browser extension rewrites elements' inline `style` attributes on the client, after Nuxt has already server-rendered them — so any component using an inline `style` (like the cyan "·" separator spans) ends up with mismatched HTML between server and client, and Vue logs a hydration-mismatch warning on every page load for any visitor running that extension. This meta tag is Dark Reader's own opt-out signal, telling the extension to leave the page alone instead of trying to work around the mismatch after the fact.
- **301 redirects (`routeRules`).** The old site served French content at root-level URLs (e.g. `/generalites/reseau/nat`, no locale prefix, on a separate `french` git branch). Restructuring into a single repo with `@nuxtjs/i18n`'s `/fr/...` prefix changed every French URL, which would otherwise break external links, bookmarks, and search-engine rankings for those pages built up over time. All 44 mappings use `statusCode: 301` explicitly — Nitro's default redirect status is 307 (temporary), which search engines don't treat as "please re-index this at the new URL" the way a 301 (permanent) does. There's deliberately **no** `/``/fr` redirect: root already serves English by default, and `@nuxtjs/i18n`'s `detectBrowserLanguage` already handles sending French-browser visitors to `/fr` automatically — a static redirect rule would just fight with that. - **301 redirects (`routeRules`).** The old site served French content at root-level URLs (e.g. `/generalites/reseau/nat`, no locale prefix, on a separate `french` git branch). Restructuring into a single repo with `@nuxtjs/i18n`'s `/fr/...` prefix changed every French URL, which would otherwise break external links, bookmarks, and search-engine rankings for those pages built up over time. All 44 mappings use `statusCode: 301` explicitly — Nitro's default redirect status is 307 (temporary), which search engines don't treat as "please re-index this at the new URL" the way a 301 (permanent) does. There's deliberately **no** `/``/fr` redirect: root already serves English by default, and `@nuxtjs/i18n`'s `detectBrowserLanguage` already handles sending French-browser visitors to `/fr` automatically — a static redirect rule would just fight with that.
- **`site.trailingSlash: true`.** The site builds as a static export (`nuxt build`, deployed as static files on a web server), and Nitro's default `prerender.autoSubfolderIndex` writes every route as `path/index.html`. A static web server serving that structure 301/308-redirects a bare `path` request to `path/`, so canonical/og:url/sitemap URLs need to already carry the trailing slash — otherwise the canonical tag points at the very URL the server redirects away from, a loop that keeps the page out of search results. This is documented, official behavior for the wider Nuxt SEO ecosystem (`nuxtseo.com`'s "Trailing Slashes" guide), but Docus doesn't depend on `nuxt-seo-utils` for its canonical/og:url logic — it hand-rolls its own in `useSeo.ts` via a plain `joinURL(site.url, route.path)` that never checks this setting. That gap is why the two items below exist alongside it.
- **`experimental.defaults.nuxtLink.trailingSlash: 'append'`.** The native Nuxt-core (not `@nuxtjs/i18n`'s own, separate `trailingSlash` option — that one only affects `switchLocalePath()`, and combining it with the middleware below double-appends the slash on hreflang alternate links) way to make every `<NuxtLink>` href, including the ones i18n's `switchLocalePath` builds for hreflang tags, resolve with a trailing slash already — so internal navigation never triggers the redirect from `app/middleware/trailing-slash.global.ts` in the first place.
## `app/middleware/trailing-slash.global.ts`
Global route middleware (new; no Docus equivalent) that 301-redirects any route whose path doesn't already end in `/` to the slash-terminated version (skipping paths with a `.`, so actual files like `/sitemap.xml` or `/favicon.ico` are left alone). This exists because setting `site.trailingSlash` alone does nothing for the *incoming* request: Docus's `useSeo.ts` reads the current `route.path` as-is, so a visitor (or crawler) landing on a bare path without the slash still gets a canonical tag pointing at that same bare path. Redirecting first means `route.path` already carries the slash by the time `useSeo.ts` runs, which fixes canonical, og:url, hreflang, and JSON-LD all at once without duplicating any of Docus's composable. This also runs during prerendering, so Nitro's link-crawler discovers and renders each page under its slash-terminated URL.
## `server/routes/sitemap.xml.ts`
Overrides Docus's own `sitemap.xml` route (`node_modules/docus/server/routes/sitemap.xml.ts`), for two reasons:
- Docus's version resolves the site URL via `inferSiteURL()`, which only reads deployment-platform env vars (Vercel/Netlify/Cloudflare Pages, or `NUXT_PUBLIC_SITE_URL`/`NUXT_SITE_URL`) — never the `site.url` set in this project's `nuxt.config.ts`. In `nuxt dev` none of those env vars exist, so every `<loc>` came out as a bare relative path instead of an absolute URL, which is invalid per the sitemap spec.
- Even where that env var happens to be set, Docus's version builds each `<loc>` with plain string concatenation and has no concept of `site.trailingSlash` at all, so it could never match the trailing-slash canonical/og:url above.
This override is otherwise a straight copy of Docus's route, with the URL-building swapped for `createSitePathResolver()` (from `nuxt-site-config`), which resolves from the same `site` config as canonical/og:url and honors `trailingSlash` correctly.
## `content.config.ts` ## `content.config.ts`
+17
View File
@@ -0,0 +1,17 @@
// The build produces a static site (Nitro's default `autoSubfolderIndex`
// writes every route as `path/index.html`), and the web server that serves
// those files 301-redirects a bare directory path to its slash-terminated
// form. Docus's own canonical/og:url logic (useSeo.ts) just does
// `joinURL(site.url, route.path)`, with no awareness of that, so any link
// or bookmark missing the slash renders a canonical tag pointing right back
// at itself, minus the slash the server then redirects to: a loop that
// keeps the page out of the index. Redirecting here, before the page ever
// renders, means `route.path` already carries the slash everywhere that
// matters (canonical, og:url, hreflang, JSON-LD, the sitemap).
export default defineNuxtRouteMiddleware((to) => {
if (to.path.endsWith('/')) return
// Leave actual files (sitemap.xml, favicon.ico, ...) alone.
if (to.path.includes('.')) return
return navigateTo(to.fullPath + '/', { redirectCode: 301 })
})
+18
View File
@@ -34,6 +34,13 @@ export default defineNuxtConfig({
site: { site: {
url: 'https://docu.djeex.fr', url: 'https://docu.djeex.fr',
name: 'Docudjeex', name: 'Docudjeex',
// The build produces a static site (Nitro's default `autoSubfolderIndex`
// writes every route as `path/index.html`), so canonical/og:url/sitemap
// must carry the trailing slash too, matching what's actually on disk.
// Without this, canonical points to the no-slash URL while the static
// host's directory redirect sends visitors (and crawlers) to the slash
// version, creating a redirect loop that keeps pages out of the index.
trailingSlash: true,
}, },
app: { app: {
head: { head: {
@@ -69,6 +76,17 @@ export default defineNuxtConfig({
}, },
}, },
}, },
// Keeps <NuxtLink> hrefs (including the ones i18n's switchLocalePath
// builds for hreflang) consistent with the trailing-slash URLs enforced
// by app/middleware/trailing-slash.global.ts, so internal navigation
// never triggers that redirect either.
experimental: {
defaults: {
nuxtLink: {
trailingSlash: 'append',
},
},
},
i18n: { i18n: {
defaultLocale: 'en', defaultLocale: 'en',
locales: [{ locales: [{
+104
View File
@@ -0,0 +1,104 @@
import { queryCollection } from '@nuxt/content/server'
import { getAvailableLocales, getCollectionsToQuery, isNavigationPath } from 'docus/server/utils/content'
// Overrides Docus's own sitemap.xml route: theirs resolves the site URL via
// `inferSiteURL()` (docus/utils/meta.ts), which only reads deployment-platform
// env vars (Vercel/Netlify/Cloudflare Pages) or NUXT_PUBLIC_SITE_URL. On a
// plain self-hosted build none of those are set, so it silently falls back to
// an empty string and every <loc> ends up as a bare path instead of an
// absolute URL, which is invalid per the sitemap spec. `createSitePathResolver`
// builds each URL from the `site` config in nuxt.config.ts instead (the same
// source canonical/og:url already use), including the `trailingSlash` setting,
// so every URL in the sitemap stays consistent with those.
interface SitemapUrl {
loc: string
lastmod?: string
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const resolveUrl = createSitePathResolver(event, { absolute: true })
const availableLocales = getAvailableLocales(config.public as Record<string, unknown>)
const collections = getCollectionsToQuery(undefined, availableLocales)
if (availableLocales.length > 0) {
for (const locale of availableLocales) {
collections.push(`landing_${locale}`)
}
}
else {
collections.push('landing')
}
const urls: SitemapUrl[] = []
for (const collection of collections) {
try {
const pages = await (queryCollection as unknown as (
event: unknown,
collection: string,
) => { all: () => Promise<Array<Record<string, unknown> & { path?: string }>> })(event, collection).all()
for (const page of pages) {
const meta = page.meta as Record<string, unknown>
const pagePath = page.path || '/'
// Skip pages with sitemap: false in frontmatter
if (meta.sitemap === false) continue
// Skip .navigation files (used for navigation configuration)
if (isNavigationPath(pagePath)) continue
const urlEntry: SitemapUrl = {
loc: pagePath,
}
// Add lastmod if available (modifiedAt from content)
if (meta.modifiedAt && typeof meta.modifiedAt === 'string') {
urlEntry.lastmod = meta.modifiedAt.split('T')[0] // Use date part only (YYYY-MM-DD)
}
urls.push(urlEntry)
}
}
catch {
// Collection might not exist, skip it
}
}
const sitemap = generateSitemap(urls, resolveUrl)
setResponseHeader(event, 'content-type', 'application/xml')
return sitemap
})
function generateSitemap(urls: SitemapUrl[], resolveUrl: (path: string) => string): string {
const urlEntries = urls
.map((url) => {
const loc = resolveUrl(url.loc)
let entry = ` <url>\n <loc>${escapeXml(loc)}</loc>`
if (url.lastmod) {
entry += `\n <lastmod>${escapeXml(url.lastmod)}</lastmod>`
}
entry += `\n </url>`
return entry
})
.join('\n')
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlEntries}
</urlset>`
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}