| 1 | import type { Metadata } from "next"; |
| 2 | import localFont from "next/font/local"; |
| 3 | import { notFound } from "next/navigation"; |
| 4 | import { Nav } from "@/components/nav"; |
| 5 | import { Footer } from "@/components/footer"; |
| 6 | import { UsageCounting } from "@/components/usage-counting"; |
| 7 | import { BUILD_FACTS } from "@/lib/facts"; |
| 8 | import { isValidLocale, localeDirection, locales } from "@/lib/i18n/config"; |
| 9 | import { getChrome, getHome } from "@/lib/i18n/dictionaries"; |
| 10 | import { serializeJsonLd } from "@/lib/json-ld"; |
| 11 | import { buildPageMetadata } from "@/lib/page-meta"; |
| 12 | import { buildSiteJsonLd } from "@/lib/site-schema"; |
| 13 | import { metadata as notFoundMetadata } from "./not-found"; |
| 14 | import "../globals.css"; |
| 15 | |
| 16 | // Shannon Sans is the one face, as in the GPUI app (`set_theme`). The pinned |
| 17 | // variable font is split by scripts/subset-shannon-sans.py into a Latin face, |
| 18 | // the only font preloaded, and an extended face (latin-ext, Greek, Cyrillic, |
| 19 | // Devanagari) the browser fetches only when a page contains one of its |
| 20 | // glyphs. The unicode ranges must match what that script prints. Code uses |
| 21 | // the system monospace stack (tokens-roles.css), so no mono face loads. |
| 22 | const sansLatin = localFont({ |
| 23 | src: "../../public/brand/fonts/ShannonSans-Variable-latin.woff2", |
| 24 | weight: "100 900", |
| 25 | style: "normal", |
| 26 | variable: "--font-shannon-latin", |
| 27 | display: "swap", |
| 28 | // The extended face's metric-matched fallback sits last in the stack; a |
| 29 | // fallback here would claim every non-Latin glyph before the extended face. |
| 30 | adjustFontFallback: false, |
| 31 | declarations: [ |
| 32 | { |
| 33 | prop: "unicode-range", |
| 34 | value: |
| 35 | "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2190-2199, U+2212-2215, U+FEFF, U+FFFD", |
| 36 | }, |
| 37 | ], |
| 38 | }); |
| 39 | |
| 40 | const sansExt = localFont({ |
| 41 | src: "../../public/brand/fonts/ShannonSans-Variable-ext.woff2", |
| 42 | weight: "100 900", |
| 43 | style: "normal", |
| 44 | variable: "--font-shannon-ext", |
| 45 | display: "swap", |
| 46 | preload: false, |
| 47 | declarations: [ |
| 48 | { |
| 49 | prop: "unicode-range", |
| 50 | value: |
| 51 | "U+0100-02BA, U+02BD-02C5, U+02C7-02D9, U+02DB, U+02DD-0303, U+0305-0307, U+0309-0328, U+032A-052F, U+0900-097F, U+10FB, U+1AB0-1ACE, U+1C80-1C88, U+1D00-1FFF, U+2070-209C, U+20A0-20AB, U+20AD-20C0, U+20F0, U+2100-2121, U+2123-218F, U+25CC, U+2C60-2C7F, U+2DE0-2E5D, U+A640-A69F, U+A700-A7FF, U+A8FF, U+A92E, U+AB30-AB6B, U+FB00-FB06, U+FE00-FE2F, U+FFFC, U+10780-107BA, U+1DF00-1DF1E", |
| 52 | }, |
| 53 | ], |
| 54 | }); |
| 55 | |
| 56 | export function generateStaticParams() { |
| 57 | return locales.map((locale) => ({ locale })); |
| 58 | } |
| 59 | |
| 60 | export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> { |
| 61 | const { locale } = await params; |
| 62 | // The layout answers not-found for this; without the guard the home page's |
| 63 | // title, canonical, and hreflang stream into that 404 (`/wp-login.php`). |
| 64 | // Throwing here instead leaves the page with no title at all. |
| 65 | if (!isValidLocale(locale)) return notFoundMetadata; |
| 66 | const home = getHome(locale); |
| 67 | return buildPageMetadata({ |
| 68 | path: "/", |
| 69 | locale, |
| 70 | title: home.metaTitle, |
| 71 | description: home.metaDescription, |
| 72 | }); |
| 73 | } |
| 74 | |
| 75 | export default async function LocaleLayout({ |
| 76 | children, |
| 77 | params, |
| 78 | }: { |
| 79 | children: React.ReactNode; |
| 80 | params: Promise<{ locale: string }>; |
| 81 | }) { |
| 82 | const { locale } = await params; |
| 83 | // Dotted paths bypass locale redirection so real files keep resolving. |
| 84 | // An unknown path such as /foo.txt (or /foo.txt/faq) still binds `[locale]` |
| 85 | // here. Reject it before reading dictionaries or rendering home chrome, |
| 86 | // so nonexistent files never become HTTP 200 pages with a fake html lang. |
| 87 | if (!isValidLocale(locale)) notFound(); |
| 88 | const chrome = getChrome(locale); |
| 89 | // RTL locales (e.g. ar) set the document direction from the canonical |
| 90 | // registry so the browser handles bidirectional layout from the root. |
| 91 | const dir = localeDirection(locale); |
| 92 | const siteJsonLd = buildSiteJsonLd(locale); |
| 93 | |
| 94 | return ( |
| 95 | <html |
| 96 | lang={locale} |
| 97 | dir={dir} |
| 98 | className={`${sansLatin.variable} ${sansExt.variable}`} |
| 99 | suppressHydrationWarning |
| 100 | > |
| 101 | <body> |
| 102 | <script |
| 103 | type="application/ld+json" |
| 104 | dangerouslySetInnerHTML={{ __html: serializeJsonLd(siteJsonLd) }} |
| 105 | /> |
| 106 | {/* Site-wide theme, applied before paint so there is no flash. With |
| 107 | no pin, the stylesheet's `prefers-color-scheme` rules resolve the |
| 108 | OS appearance (and track it live); a stored "light" or "dark" |
| 109 | (`cw-theme`, see components/theme-toggle.tsx) pins the scheme. |
| 110 | "system", a legacy "auto", or no choice leaves it to the OS. */} |
| 111 | <script |
| 112 | dangerouslySetInnerHTML={{ |
| 113 | __html: |
| 114 | "(function(){try{var t=localStorage.getItem('cw-theme');if(t==='light'||t==='dark'){document.documentElement.setAttribute('data-theme',t);}}catch(e){}})();", |
| 115 | }} |
| 116 | /> |
| 117 | <a href="#main-content" className="skip-link"> |
| 118 | {chrome.skipToContent} |
| 119 | </a> |
| 120 | <Nav locale={locale} /> |
| 121 | <main id="main-content">{children}</main> |
| 122 | <Footer locale={locale} /> |
| 123 | {/* Aggregate usage counting, on by default — see lib/telemetry. The |
| 124 | choice lives on the privacy page; every opt-out stays off. */} |
| 125 | <UsageCounting appVersion={BUILD_FACTS.version ?? "0.0.0"} /> |
| 126 | </body> |
| 127 | </html> |
| 128 | ); |
| 129 | } |
| 130 |