Fix canonical URL and sitemap trailing slash consistency
This commit is contained in:
@@ -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.
|
||||
- **`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.
|
||||
- **`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`
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -34,6 +34,13 @@ export default defineNuxtConfig({
|
||||
site: {
|
||||
url: 'https://docu.djeex.fr',
|
||||
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: {
|
||||
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: {
|
||||
defaultLocale: 'en',
|
||||
locales: [{
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
Reference in New Issue
Block a user