diff --git a/frontend/index.html b/frontend/index.html index fee5bd3..236eb80 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,30 +9,8 @@ -
-
- -
- -
+
+ diff --git a/frontend/package.json b/frontend/package.json index e5b2e34..134535e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,12 +5,14 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", - "postbuild": "node scripts/generate-sitemap.js", + "build": "tsc -b && npm run build:client && npm run build:server", + "build:client": "vite build --outDir dist/client", + "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server", + "start": "node server.mjs", "lint": "eslint .", "test": "vitest", "test:run": "vitest run", - "preview": "vite preview" + "preview": "vite preview --outDir dist/client" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/frontend/server.mjs b/frontend/server.mjs new file mode 100644 index 0000000..bbdb193 --- /dev/null +++ b/frontend/server.mjs @@ -0,0 +1,75 @@ +import { createReadStream } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { render } from './dist/server/entry-server.js' + +const root = fileURLToPath (new URL ('./dist/client/', import.meta.url)) +const template = await readFile (path.join (root, 'index.html'), 'utf8') +const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin +const apiBaseURL = process.env.SSR_API_BASE_URL ?? 'http://127.0.0.1:3002' +const types = { + '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', + '.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webp': 'image/webp', + '.woff': 'font/woff', '.woff2': 'font/woff2', '.otf': 'font/otf', + '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.txt': 'text/plain' } + +if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN) + throw new Error ('PUBLIC_ORIGIN is required in production') + +createServer (async (request, response) => { + response.setHeader ('Cache-Control', 'no-store') + if (!['GET', 'HEAD'].includes (request.method)) + { + response.writeHead (405, { Allow: 'GET, HEAD' }).end () + return + } + try + { + const url = new URL (request.url, origin) + const pathname = decodeURIComponent (url.pathname) + const file = path.resolve (root, '.' + pathname) + const allowed = file.startsWith (root) + && !pathname.split ('/').some (segment => segment.startsWith ('.')) + && pathname !== '/index.html' + const info = allowed ? await stat (file).catch (() => null) : null + if (info?.isFile ()) + { + response.writeHead (200, { + 'Content-Type': types[path.extname (file)] ?? 'application/octet-stream', + 'Content-Length': info.size, + 'Cache-Control': /\/[^/]+-[\w-]{8}\.[^.]+$/.test (pathname) + ? 'public, max-age=31536000, immutable' : 'public, max-age=3600' }) + if (request.method === 'HEAD') + response.end () + else + createReadStream (file).on ('error', () => response.destroy ()).pipe (response) + return + } + if (pathname.startsWith ('/assets/')) + { + response.writeHead (404).end () + return + } + const result = await render (url.pathname + url.search, origin, apiBaseURL) + const html = result == null ? template : template + .replace ('lang="ja"', `lang="${result.locale}"`) + .replace (/[\s\S]*?<\/title>/, '') + .replace (/<meta name="description"[^>]*>/, '') + .replace ('<!-- helmet -->', () => result.head) + .replace ('<!-- outlet -->', () => result.html) + .replace ('<!-- ssr-state -->', + () => `<script id="ssr-state" type="application/json">${result.bootstrap}</script>`) + response.writeHead (result?.status ?? 200, { + 'Content-Type': 'text/html; charset=utf-8' }) + response.end (request.method === 'HEAD' ? undefined : html) + } + catch (error) + { + console.error (error) + response.writeHead (error instanceof URIError ? 400 : 503).end () + } +}).listen (Number (process.env.PORT ?? 5173), process.env.HOST ?? '127.0.0.1') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 60692a8..23c82e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' -import { Fragment, Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { createBrowserRouter, Navigate, @@ -33,47 +33,48 @@ import { applyClientAnimationMode, import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' -import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' -import MaterialBasePage from '@/pages/materials/MaterialBasePage' -import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' -import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage' -import MaterialListPage from '@/pages/materials/MaterialListPage' -import MaterialNewPage from '@/pages/materials/MaterialNewPage' -import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage' -import MorePage from '@/pages/MorePage' -import GekanatorUnavailablePage from '@/pages/GekanatorUnavailablePage' -import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' -import TOSPage from '@/pages/TOSPage' import HelpDetailPage from '@/pages/help/HelpDetailPage' -import HelpChangesPage from '@/pages/help/HelpChangesPage' -import HelpDiffPage from '@/pages/help/HelpDiffPage' -import HelpListPage from '@/pages/help/HelpListPage' -import PostDetailPage from '@/pages/posts/PostDetailPage' -import PostHistoryPage from '@/pages/posts/PostHistoryPage' -import PostNewPage from '@/pages/posts/PostNewPage' -import PostListPage from '@/pages/posts/PostListPage' -import PostSearchPage from '@/pages/posts/PostSearchPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' -import SettingPage from '@/pages/users/SettingPage' -import TagDetailPage from '@/pages/tags/TagDetailPage' -import TagHistoryPage from '@/pages/tags/TagHistoryPage' -import TagListPage from '@/pages/tags/TagListPage' -import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' -import WikiDetailPage from '@/pages/wiki/WikiDetailPage' -import WikiDiffPage from '@/pages/wiki/WikiDiffPage' -import WikiEditPage from '@/pages/wiki/WikiEditPage' -import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage' -import WikiNewPage from '@/pages/wiki/WikiNewPage' -import WikiSearchPage from '@/pages/wiki/WikiSearchPage' -import type { Dispatch, FC, SetStateAction } from 'react' +import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { ClientAnimationMode } from '@/lib/settings' import type { AvailableLocale } from '@/lib/locales' import type { User } from '@/types' const GekanatorPage = lazy (() => import ('@/pages/GekanatorPage')) +const DeerjikistDetailPage = lazy (() => import ('@/pages/deerjikists/DeerjikistDetailPage')) +const MaterialBasePage = lazy (() => import ('@/pages/materials/MaterialBasePage')) +const MaterialDetailPage = lazy (() => import ('@/pages/materials/MaterialDetailPage')) +const MaterialHistoryPage = lazy (() => import ('@/pages/materials/MaterialHistoryPage')) +const MaterialListPage = lazy (() => import ('@/pages/materials/MaterialListPage')) +const MaterialNewPage = lazy (() => import ('@/pages/materials/MaterialNewPage')) +const MaterialSyncSuppressionsPage = lazy ( + () => import ('@/pages/materials/MaterialSyncSuppressionsPage')) +const MorePage = lazy (() => import ('@/pages/MorePage')) +const GekanatorUnavailablePage = lazy (() => import ('@/pages/GekanatorUnavailablePage')) +const NicoTagListPage = lazy (() => import ('@/pages/tags/NicoTagListPage')) +const TOSPage = lazy (() => import ('@/pages/TOSPage')) +const HelpChangesPage = lazy (() => import ('@/pages/help/HelpChangesPage')) +const HelpDiffPage = lazy (() => import ('@/pages/help/HelpDiffPage')) +const HelpListPage = lazy (() => import ('@/pages/help/HelpListPage')) +const PostDetailPage = lazy (() => import ('@/pages/posts/PostDetailPage')) +const PostHistoryPage = lazy (() => import ('@/pages/posts/PostHistoryPage')) +const PostNewPage = lazy (() => import ('@/pages/posts/PostNewPage')) +const PostListPage = lazy (() => import ('@/pages/posts/PostListPage')) +const PostSearchPage = lazy (() => import ('@/pages/posts/PostSearchPage')) +const SettingPage = lazy (() => import ('@/pages/users/SettingPage')) +const TagDetailPage = lazy (() => import ('@/pages/tags/TagDetailPage')) +const TagHistoryPage = lazy (() => import ('@/pages/tags/TagHistoryPage')) +const TagListPage = lazy (() => import ('@/pages/tags/TagListPage')) +const TheatreDetailPage = lazy (() => import ('@/pages/theatres/TheatreDetailPage')) +const WikiDetailPage = lazy (() => import ('@/pages/wiki/WikiDetailPage')) +const WikiDiffPage = lazy (() => import ('@/pages/wiki/WikiDiffPage')) +const WikiEditPage = lazy (() => import ('@/pages/wiki/WikiEditPage')) +const WikiHistoryPage = lazy (() => import ('@/pages/wiki/WikiHistoryPage')) +const WikiNewPage = lazy (() => import ('@/pages/wiki/WikiNewPage')) +const WikiSearchPage = lazy (() => import ('@/pages/wiki/WikiSearchPage')) const GekanatorRoute = ({ user }: { user: User | null }) => { @@ -137,12 +138,9 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: { <Route path="*" element={<NotFound/>}/> </Routes>) - if (animationMode === 'off') - return routes - return ( - <AnimatePresence mode="wait"> - {routes} + <AnimatePresence mode="wait" initial={animationMode !== 'off'}> + <Suspense fallback={null}>{routes}</Suspense> </AnimatePresence>) } @@ -159,7 +157,6 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { const [status, setStatus] = useState (200) const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' - const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup const appLayoutTransition = useMemo ( () => ( @@ -265,7 +262,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { ? 'user' : 'always' }> - <LayoutWrapper> + <LayoutGroup> <motion.div layout={animationMode === 'off' ? false : 'position'} transition={{ layout: appLayoutTransition }} @@ -276,7 +273,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { user={user} setUser={setUser}/> </motion.div> - </LayoutWrapper> + </LayoutGroup> </MotionConfig> <Toaster/> @@ -339,17 +336,23 @@ const LocaleRedirect = ({ to }: { to: string }) => { } -const router = createBrowserRouter ([ +export const appRoutes = [ { path: '/:locale/*', element: <LocaleRoute/> }, - { path: '*', element: <LocaleRoute/> }]) + { path: '*', element: <LocaleRoute/> }] -const App: FC = () => ( +export const AppFrame = ({ children }: { children: ReactNode }) => ( <> <RouteBlockerOverlay/> {import.meta.env.DEV && <DevModeWatermark/>} - <RouterProvider router={router}/> + {children} </>) +const App: FC = () => { + const [router] = useState (() => createBrowserRouter (appRoutes)) + return <AppFrame><RouterProvider router={router}/></AppFrame> +} + + export default App diff --git a/frontend/src/components/ErrorScreen.tsx b/frontend/src/components/ErrorScreen.tsx index ee40afc..8427747 100644 --- a/frontend/src/components/ErrorScreen.tsx +++ b/frontend/src/components/ErrorScreen.tsx @@ -27,20 +27,13 @@ const ErrorScreen: FC<Props> = ({ status }) => { const title = message.replaceAll ('(', ' (').replaceAll (')', ')') - const style = document.createElement ('style') - style.innerHTML = ` - @font-face - { - font-family: 'Nikumaru'; - src: url(${ nikumaru }) format('opentype'); - }` - document.head.appendChild (style) - return ( <MainArea> <PageHead title={{ text: title, language: translate (`${status}.message`).language }}> <meta name="robots" content="noindex"/> + <style>{`@font-face { font-family: 'Nikumaru'; + src: url(${nikumaru}) format('opentype'); }`}</style> </PageHead> <div className="text-6xl font-bold text-transparent bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)] diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index f7ef056..01a4c01 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -9,6 +9,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' +import { currentRenderContext } from '@/lib/renderContext' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { To } from 'react-router-dom' @@ -47,7 +48,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({ [to, locale], ) const url = useMemo (() => { - return (new URL (path, window.location.origin)).toString () + return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString () }, [path]) const setOverlay = useOverlayStore (s => s.setActive) diff --git a/frontend/src/components/common/PageHead.tsx b/frontend/src/components/common/PageHead.tsx index 5490eeb..109a8f9 100644 --- a/frontend/src/components/common/PageHead.tsx +++ b/frontend/src/components/common/PageHead.tsx @@ -9,10 +9,14 @@ type LocalisedTitle = { text: string; language: string } type Props = { title?: string | LocalisedTitle includeSiteName?: boolean + description?: LocalisedTitle + canonical?: string children?: ReactNode } -const PageHead = ({ title, includeSiteName = true, children }: Props) => { +const PageHead = ( + { title, includeSiteName = true, description: pageDescription, canonical, children }: Props, +) => { const locale = useLocale () const { message, siteTitle } = useUITranslation ('shared') const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title @@ -20,12 +24,13 @@ const PageHead = ({ title, includeSiteName = true, children }: Props) => { pageTitle == null ? siteTitle.text : (includeSiteName ? `${pageTitle.text} | ${siteTitle.text}` : pageTitle.text) - const description = message ('head.description', { brand: siteTitle.text }) + const description = pageDescription ?? message ('head.description', { brand: siteTitle.text }) return ( <Helmet> <title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle} + {canonical != null && } {children} ) } diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx new file mode 100644 index 0000000..9da9bb6 --- /dev/null +++ b/frontend/src/entry-server.tsx @@ -0,0 +1,79 @@ +import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query' +import { renderToString } from 'react-dom/server' +import { HelmetProvider } from 'react-helmet-async' +import { I18nextProvider } from 'react-i18next' +import { createStaticHandler, createStaticRouter, + StaticRouterProvider } from 'react-router-dom/server' + +import { AppFrame, appRoutes } from '@/App' +import { matchHelpDetail, prepareHelpPage } from '@/lib/help' +import { createUII18n } from '@/lib/i18n' +import { fetchLocales, isLocaleCode } from '@/lib/locales' +import { localesKeys } from '@/lib/queryKeys' +import { withRenderContext } from '@/lib/renderContext.server' + +import type { HelmetServerState } from 'react-helmet-async' + +type RenderedPage = { + status: number + html: string + head: string + locale: string + bootstrap: string } + + +export const render = async ( + address: string, + origin: string, + apiBaseURL: string, +): Promise => { + const url = new URL (address, origin) + const match = matchHelpDetail (url.pathname) + if (match == null) + return null + const locale = isLocaleCode (match[1]) ? match[1] : 'ja' + return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => { + const client = new QueryClient ({ defaultOptions: { + queries: { retry: false, gcTime: Infinity } } }) + try + { + const locales = await client.fetchQuery ({ + queryKey: localesKeys.index, queryFn: fetchLocales }) + const active = isLocaleCode (match[1]) + && locales.locales.some (entry => entry.code === match[1]) + const status = active ? await prepareHelpPage (client, url) : 404 + const instance = createUII18n (locale) + const handler = createStaticHandler (appRoutes) + const context = await handler.query (new Request (url)) + if (context instanceof Response) + throw new Error (`Unexpected router response: ${context.status}`) + const router = createStaticRouter (handler.dataRoutes, context) + const helmetContext: { helmet?: HelmetServerState } = { } + const html = renderToString ( + + + + + + + + + ) + const helmet = helmetContext.helmet! + const head = [helmet.title, helmet.meta, helmet.link, helmet.style] + .map (entry => entry.toString ()).join ('') + const state = dehydrate (client, { shouldDehydrateQuery: query => + query.state.status === 'success' + && (query.queryKey[0] === 'locales' + || (query.queryKey[0] === 'help' && query.queryKey[1] === 'article')) }) + const bootstrap = JSON.stringify ({ state, locale, url: url.pathname + url.search }) + .replace (/ { const { locale, ...config } = opt ?? { } return { ...config, + ...(currentRenderContext ()?.apiBaseURL != null + && { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }), ...(locale != null && { params: { locale, ...config.params } }), - headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '', + headers: { 'X-Transfer-Code': typeof window === 'undefined' + ? '' : localStorage.getItem ('user_code') ?? '', ...(config.headers ?? { }) } } } diff --git a/frontend/src/lib/help.ts b/frontend/src/lib/help.ts index 65b0c3a..06128a2 100644 --- a/frontend/src/lib/help.ts +++ b/frontend/src/lib/help.ts @@ -1,3 +1,7 @@ +import { isLocaleCode, localeLanguage } from '@/lib/locales' +import { helpKeys } from '@/lib/queryKeys' + +import type { QueryClient } from '@tanstack/react-query' import type { ComponentType } from 'react' type ArticleLanguage = { currentEdition: string; publishedEditions: string[] } @@ -74,7 +78,7 @@ export const compareHelpEditions = (a: string, b: string): number => { export const helpEditionDate = (edition: string): Date | null => { const parsed = parseHelpEdition (edition) - return parsed == null ? null : new Date (`${ parsed.date }T00:00:00`) + return parsed == null ? null : new Date (`${ parsed.date }T00:00:00+09:00`) } @@ -93,6 +97,44 @@ export const resolveHelpLanguage = ( : null +export const matchHelpDetail = (pathname: string): RegExpMatchArray | null => { + const matched = pathname.match (/^\/([^/]+)\/help\/([^/]+)\/?$/) + return matched?.[2] === 'changes' ? null : matched +} + + +// MDX functions stay in memory; both entries load them before rendering. +export const prepareHelpPage = async (client: QueryClient, url: URL): Promise => { + const matched = matchHelpDetail (url.pathname) + if (matched == null || !(isLocaleCode (matched[1]))) + return 404 + let identifier: string + try + { + identifier = decodeURIComponent (matched[2]) + } + catch + { + return 404 + } + const article = await client.fetchQuery ({ + queryKey: helpKeys.article (identifier), + queryFn: () => getHelpArticle (identifier), staleTime: Infinity }) + const language = resolveHelpLanguage (article, localeLanguage (matched[1])) + if (language == null) + return 404 + const meta = article!.languages[language] + const edition = url.searchParams.get ('edition') ?? meta.currentEdition + if (!(meta.publishedEditions.includes (edition)) || parseHelpEdition (edition) == null) + return 404 + const content = await client.fetchQuery ({ + queryKey: helpKeys.edition (identifier, language, edition), + queryFn: () => getHelpEditionContent (identifier, language, edition), + staleTime: Infinity }) + return content == null ? 404 : 200 +} + + export const getHelpEditions = async ( identifier: string, language: string, diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 5ac2004..2e8eb6f 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -1,13 +1,10 @@ import { createInstance } from 'i18next' import { initReactI18next } from 'react-i18next' -import type { TOptions } from 'i18next' +import type { i18n as I18n, TOptions } from 'i18next' import type { LocaleCode } from '@/lib/locales' -export const i18n = createInstance () - - // Keep Japanese placeholders editable without reporting them as completed translations. const translatedResource = (resource: Record): Record => { const untranslated = new Set ( @@ -42,23 +39,31 @@ for (const [path, dictionary] of Object.entries (dictionaries)) const namespaces = Object.keys (resources.ja ?? { }) -void i18n.use (initReactI18next).init ({ - lng: 'ja', - fallbackLng: 'ja', - supportedLngs: ['ja', 'ain-Kana'], - load: 'currentOnly', - initAsync: false, - defaultNS: 'navigation', - ns: namespaces, - resources, - interpolation: { escapeValue: false }, - returnNull: false, - returnEmptyString: false, - saveMissing: import.meta.env.DEV, - missingKeyHandler: (languages, namespace, key) => { - console.warn ('[i18n] missing key', { languages, namespace, key }) - }, - react: { useSuspense: false } }) +export const createUII18n = (locale: string): I18n => { + const instance = createInstance () + void instance.init ({ + lng: locale, + fallbackLng: 'ja', + supportedLngs: ['ja', 'ain-Kana'], + load: 'currentOnly', + initAsync: false, + defaultNS: 'navigation', + ns: namespaces, + resources, + interpolation: { escapeValue: false }, + returnNull: false, + returnEmptyString: false, + saveMissing: import.meta.env.DEV, + missingKeyHandler: (languages, namespace, key) => { + console.warn ('[i18n] missing key', { languages, namespace, key }) + }, + react: { useSuspense: false } }) + return instance +} + +// This instance is used only by legacy helpers with an explicit lng option. +export const i18n = createUII18n ('ja') +initReactI18next.init (i18n) export const translateUIMessage = ( @@ -66,19 +71,20 @@ export const translateUIMessage = ( namespace: string, locale: string, values: Record = { }, + instance: I18n = i18n, ): { text: string; language: string } => { const languages = new Set () const visiting = new Set () const collectLanguages = (reference: string, options: TOptions): string => { - const details = i18n.t (reference, { ...options, + const details = instance.t (reference, { ...options, returnDetails: true, returnObjects: false, nest: false, skipInterpolation: true }) const identity = `${details.usedLng}:${details.usedNS}:${details.exactUsedKey}` if (visiting.has (identity) || typeof details.res !== 'string') return details.usedLng visiting.add (identity) - const nestingOptions = { ...i18n.options.interpolation, ...options, + const nestingOptions = { ...instance.options.interpolation, ...options, ns: details.usedNS, lng: details.usedLng } - const remainder = i18n.services.interpolator.nest (details.res, + const remainder = instance.services.interpolator.nest (details.res, (nestedKey: string, nestedOptions: TOptions) => { collectLanguages (nestedKey, nestedOptions) return ' ' @@ -94,7 +100,7 @@ export const translateUIMessage = ( // A single lang attribute cannot describe mixed fallback text: use the full Japanese message. const mixed = languages.size > 1 const language = mixed ? 'ja' : languages.values ().next ().value ?? resolvedLanguage - const text = i18n.t (key, { ...options, lng: mixed ? 'ja' : resolvedLanguage, + const text = instance.t (key, { ...options, lng: mixed ? 'ja' : resolvedLanguage, returnDetails: false, returnObjects: false }) if (typeof text !== 'string') { diff --git a/frontend/src/lib/locales.ts b/frontend/src/lib/locales.ts index 7d89054..a3820d7 100644 --- a/frontend/src/lib/locales.ts +++ b/frontend/src/lib/locales.ts @@ -1,4 +1,5 @@ import { apiGet } from '@/lib/api' +import { currentRenderContext } from '@/lib/renderContext' import type { To } from 'react-router-dom' @@ -43,7 +44,7 @@ export const resolveViewingLocale = ( export const currentViewingLocale = (): LocaleCode => - resolveViewingLocale (typeof window === 'undefined' ? '' : window.location.pathname) + resolveViewingLocale (currentRenderContext ()?.pathname ?? '') export const unlocalisedPath = (pathname: string): string => { diff --git a/frontend/src/lib/renderContext.server.ts b/frontend/src/lib/renderContext.server.ts new file mode 100644 index 0000000..400233f --- /dev/null +++ b/frontend/src/lib/renderContext.server.ts @@ -0,0 +1,12 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +import type { RenderContext } from '@/lib/renderContext' + +const requests = new AsyncLocalStorage () + + +export const currentRenderContext = (): RenderContext | undefined => requests.getStore () + + +export const withRenderContext = (context: RenderContext, render: () => T): T => + requests.run (context, render) diff --git a/frontend/src/lib/renderContext.ts b/frontend/src/lib/renderContext.ts new file mode 100644 index 0000000..46e5c97 --- /dev/null +++ b/frontend/src/lib/renderContext.ts @@ -0,0 +1,13 @@ +export type RenderContext = { + pathname: string + origin: string + apiBaseURL?: string } + + +// The server build replaces this module with the AsyncLocalStorage implementation. +export const currentRenderContext = (): RenderContext | undefined => { + if (typeof window === 'undefined') + return undefined + return { pathname: window.location.pathname, + origin: window.location.origin } +} diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 8b943e2..01470ac 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -347,7 +347,8 @@ const safeParseClientSettings = (raw: string | null): ClientSettings => { export const loadClientSettings = (): ClientSettings => - safeParseClientSettings (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY)) + safeParseClientSettings (typeof window === 'undefined' + ? null : localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY)) export const saveClientSettings = (settings: ClientSettings): void => { diff --git a/frontend/src/lib/useClientBehaviourSettings.ts b/frontend/src/lib/useClientBehaviourSettings.ts index d587bd3..6cc2069 100644 --- a/frontend/src/lib/useClientBehaviourSettings.ts +++ b/frontend/src/lib/useClientBehaviourSettings.ts @@ -10,9 +10,10 @@ import type { ClientBehaviorSettings } from '@/lib/settings' export const useClientBehaviourSettings = (): ClientBehaviorSettings => { const [settings, setSettings] = - useState (() => getClientBehaviorSettings ()) + useState ({ }) useEffect (() => { + setSettings (getClientBehaviorSettings ()) const handleClientSettingsChange = () => { setSettings (getClientBehaviorSettings ()) } diff --git a/frontend/src/lib/useUITranslation.ts b/frontend/src/lib/useUITranslation.ts index b37a06a..1281b0d 100644 --- a/frontend/src/lib/useUITranslation.ts +++ b/frontend/src/lib/useUITranslation.ts @@ -8,11 +8,11 @@ import { useLocale } from '@/lib/useLocale' export const useUITranslation = (namespace = 'common') => { const locale = useLocale () - useTranslation (namespace, { lng: locale }) + const { i18n } = useTranslation (namespace, { lng: locale }) const message = useCallback ((key: string, values?: Record) => { - return translateUIMessage (key, namespace, locale, values) - }, [namespace, locale]) + return translateUIMessage (key, namespace, locale, values, i18n) + }, [namespace, locale, i18n]) const label = useCallback ((key: string, values?: Record) => { const result = message (key, values) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index da3267e..0afef1c 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -34,6 +34,7 @@ const formatDateString = ( { dateFormatters.set (unknown, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', { era: 'long', year: 'numeric', + timeZone: 'Asia/Tokyo', month: unknown === 'month' ? undefined : 'long', day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric', weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short', @@ -45,6 +46,7 @@ const formatDateString = ( } ainuDateFormatter ??= new Intl.DateTimeFormat ('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', + timeZone: 'Asia/Tokyo', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }) const parts = ainuDateFormatter.formatToParts (date) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c7aeab3..2c7ac65 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,17 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider, hydrate } from '@tanstack/react-query' +import { createRoot, hydrateRoot } from 'react-dom/client' import { HelmetProvider } from 'react-helmet-async' import { I18nextProvider } from 'react-i18next' import '@/index.css' import App from '@/App' -import { i18n } from '@/lib/i18n' +import { createUII18n } from '@/lib/i18n' +import { prepareHelpPage } from '@/lib/help' +import { currentViewingLocale } from '@/lib/locales' import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings' +import type { DehydratedState } from '@tanstack/react-query' + const helmetContext = { } const client = new QueryClient ({ @@ -16,13 +20,31 @@ const client = new QueryClient ({ gcTime: 30 * 60 * 1000, retry: 1 } } }) -applyClientAnimationMode (getClientAnimationMode ()) -createRoot (document.getElementById ('root')!).render ( - - - - - - - ) +const start = async () => { + const bootstrap = document.getElementById ('ssr-state') + if (bootstrap?.textContent) + { + const data = JSON.parse (bootstrap.textContent) as { state: DehydratedState } + hydrate (client, data.state) + await prepareHelpPage (client, new URL (window.location.href)) + } + const instance = createUII18n (currentViewingLocale ()) + const app = ( + + + + + + + ) + const root = document.getElementById ('root')! + if (bootstrap != null) + hydrateRoot (root, app) + else + createRoot (root).render (app) + applyClientAnimationMode (getClientAnimationMode ()) +} + + +void start () diff --git a/frontend/src/mdx-components.tsx b/frontend/src/mdx-components.tsx index 735fd8d..731fe8e 100644 --- a/frontend/src/mdx-components.tsx +++ b/frontend/src/mdx-components.tsx @@ -4,6 +4,7 @@ import { useLocation } from 'react-router-dom' import PrefetchLink from '@/components/PrefetchLink' import { localiseUIChildren } from '@/components/LocalisedText' import { cn } from '@/lib/utils' +import { currentRenderContext } from '@/lib/renderContext' import type { MDXComponents } from 'mdx/types' import type { ComponentProps, HTMLAttributes, ReactNode } from 'react' @@ -30,19 +31,20 @@ const MDXLink = ( { href, children, ...props }: ComponentProps<'a'>, ) => { const location = useLocation () + const origin = currentRenderContext ()?.origin ?? 'http://localhost' const url = useMemo (() => { try { - const base = new URL (location.pathname + location.search, window.location.origin) + const base = new URL (location.pathname + location.search, origin) return href ? new URL (href, base) : null } catch { return null } - }, [href, location.pathname, location.search]) + }, [href, location.pathname, location.search, origin]) const internal = url != null - && url.origin === window.location.origin + && url.origin === origin && (url.protocol === 'http:' || url.protocol === 'https:') const anchor = href?.startsWith ('#') || (url?.pathname === location.pathname diff --git a/frontend/src/pages/help/HelpDetailPage.tsx b/frontend/src/pages/help/HelpDetailPage.tsx index 4198a29..20d1282 100644 --- a/frontend/src/pages/help/HelpDetailPage.tsx +++ b/frontend/src/pages/help/HelpDetailPage.tsx @@ -14,6 +14,7 @@ import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' import { dateString } from '@/lib/utils' import { helpKeys } from '@/lib/queryKeys' +import { currentRenderContext } from '@/lib/renderContext' import { LocalisedDocument } from '@/mdx-components' import NotFound from '@/pages/NotFound' @@ -70,10 +71,19 @@ const HelpDetailPage: FC = () => { return const Content = current?.content + const origin = currentRenderContext ()?.origin + const canonical = origin == null ? undefined : new URL ( + `/${locale}/help/${encodeURIComponent (identifier)}`, origin).href + const description = current?.body.replace (/!?(\[([^\]]*)\])\([^)]*\)/g, '$2') + .replace (/[#*`<>]/g, '') + .replace (/\s+/g, language === 'ain' ? '\u3000' : ' ').trim ().slice (0, 180) return ( - + {requestedEdition && (