complete i18n migration to /[locale]/ with EN+ES content

Full rewrite of the docs site under app/[locale]/ with next-intl
in localePrefix:"always" mode. Every page now exists at both
/en/<path> and /es/<path>; the root / shows a meta-refresh + JS
redirect to /<defaultLocale>/ so GitHub Pages serves something
on the apex URL.

Highlights:
- 107 doc pages migrated to file-per-page JSON namespaces under
  messages/en/ and messages/es/. Spanish content is fully
  translated (no copy-of-English placeholders).
- New documentation for the Active Suppressions section in the
  Settings tab and the per-event Dismiss dropdown in the Health
  Monitor modal.
- New screenshots: dismiss-duration-dropdown.png and an updated
  health-suppression-settings.png.
- Pagefind integrated for client-side search; index is built on
  every CI deploy (not committed).
- RSS feeds: per-locale at /<locale>/rss.xml plus root /rss.xml
  for backward compat.
- Removed the dead app/[locale]/guides/[slug]/ route — every
  guide now has its own static page and no markdown source
  remains.
- Fixed orphan link /guides/nvidia -> /guides/nvidia-manual in
  docs/hardware/nvidia-host.
- Removed obsolete components (footer2, calendar, drawer).

Verified locally with `npm ci && npm run build`: 2804 files in
out/, 231 pages indexed by pagefind, root redirect intact, both
locale roots and the new Active Suppressions docs render OK.
This commit is contained in:
MacRimi
2026-05-31 12:41:10 +02:00
parent 875910b4d7
commit 5ca3463bf6
649 changed files with 83958 additions and 11096 deletions
+131
View File
@@ -0,0 +1,131 @@
"use client"
import { useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl"
type Heading = { id: string; text: string; level: 2 | 3 }
/**
* Right-rail table of contents for docs pages.
*
* On mount it walks every <h2> and <h3> inside `<main>` (the docs
* container), assigns each one an `id` derived from its text if it
* doesn't already have one, and renders a sticky list of anchors.
*
* Why client-side and not server-rendered? Most docs pages emit
* headings as plain JSX without ids — extracting them at build time
* would require either touching all ~107 pages or a custom MDX
* pipeline. A 15-line useEffect avoids both.
*
* Scroll-spy: an IntersectionObserver highlights the entry whose
* heading is currently in the upper half of the viewport, so the user
* can see where they are as they scroll.
*
* Only renders on xl+ screens (the layout reserves the right gutter
* there); on smaller viewports the ToC stays hidden so the article
* keeps full width.
*/
export function DocTableOfContents() {
const pathname = usePathname()
const t = useTranslations("tocPanel")
const [headings, setHeadings] = useState<Heading[]>([])
const [activeId, setActiveId] = useState<string>("")
// Re-scan whenever the route changes (so navigating between docs
// pages refreshes the ToC).
useEffect(() => {
const main = document.querySelector("main")
if (!main) {
setHeadings([])
return
}
const nodes = Array.from(main.querySelectorAll("h2, h3")) as HTMLHeadingElement[]
const used = new Set<string>()
const collected: Heading[] = nodes
.map((node) => {
const text = (node.textContent || "").trim()
if (!text) return null
// Always dedupe: if a heading carries an explicit id that already
// appeared (e.g. a card <h3>VM</h3> auto-slugged to "vm" before a
// <h2 id="vm"> later in the page), append -2, -3, ... so React
// keys stay unique. The scroll-anchor still works for the first
// occurrence; subsequent ones get their own anchor.
let base = node.id || slugify(text)
let id = base
let n = 1
while (used.has(id)) {
n += 1
id = `${base}-${n}`
}
if (!node.id || node.id !== id) {
node.id = id
}
used.add(id)
return { id, text, level: node.tagName === "H3" ? 3 : 2 } as Heading
})
.filter((h): h is Heading => h !== null)
setHeadings(collected)
}, [pathname])
// Scroll-spy: highlight the heading currently in the upper half of
// the viewport. Using rootMargin pushes the trigger zone toward the
// top so the active entry changes as you scroll, not only when the
// heading is dead-centre.
useEffect(() => {
if (headings.length === 0) return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((e) => e.isIntersecting)
if (visible.length > 0) {
// First visible entry from the top of the viewport.
visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
setActiveId(visible[0].target.id)
}
},
{ rootMargin: "-80px 0px -70% 0px", threshold: 0 },
)
headings.forEach((h) => {
const el = document.getElementById(h.id)
if (el) observer.observe(el)
})
return () => observer.disconnect()
}, [headings])
if (headings.length === 0) return null
return (
<nav
aria-label={t("onThisPage")}
className="text-sm sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto pl-4"
>
<p className="font-semibold text-gray-900 mb-3 uppercase tracking-wide text-xs">{t("onThisPage")}</p>
<ul className="space-y-1.5 border-l border-gray-200">
{headings.map((h) => (
<li key={h.id} className={h.level === 3 ? "ml-3" : ""}>
<a
href={`#${h.id}`}
className={`-ml-px block border-l-2 pl-3 py-0.5 transition-colors ${
activeId === h.id
? "border-blue-500 text-blue-600 font-medium"
: "border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300"
}`}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
)
}
function slugify(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 80)
}