diff --git a/CUSTOMIZATIONS.md b/CUSTOMIZATIONS.md index 4ca1aeb..f375a82 100644 --- a/CUSTOMIZATIONS.md +++ b/CUSTOMIZATIONS.md @@ -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 `` 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 `` 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 `` 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` diff --git a/app/middleware/trailing-slash.global.ts b/app/middleware/trailing-slash.global.ts new file mode 100644 index 0000000..2b86b1b --- /dev/null +++ b/app/middleware/trailing-slash.global.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 }) +}) diff --git a/nuxt.config.ts b/nuxt.config.ts index 556d251..17407da 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -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 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: [{ diff --git a/server/routes/sitemap.xml.ts b/server/routes/sitemap.xml.ts new file mode 100644 index 0000000..d669d86 --- /dev/null +++ b/server/routes/sitemap.xml.ts @@ -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 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) + 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 & { path?: string }>> })(event, collection).all() + + for (const page of pages) { + const meta = page.meta as Record + 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 = ` \n ${escapeXml(loc)}` + + if (url.lastmod) { + entry += `\n ${escapeXml(url.lastmod)}` + } + + entry += `\n ` + return entry + }) + .join('\n') + + return ` + +${urlEntries} +` +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +}