このコミットが含まれているのは:
2026-10-18 18:54:59 +09:00
コミット 912b941f90
22個のファイルの変更、391行の追加、133行の削除
+2 -24
ファイルの表示
@@ -9,30 +9,8 @@
<!-- helmet -->
</head>
<body>
<div id="root">
<div class="flex flex-col h-screen w-screen">
<nav class="px-3 flex justify-between items-center w-full min-h-[48px]
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<div class="flex items-center gap-2 h-full">
<a class="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
href="/">
ぼざクリ タグ広場
</a>
<a class="hidden md:flex h-full items-center px-2" href="/posts">広場</a>
<a class="hidden md:flex h-full items-center px-2" href="/tags">タグ</a>
<a class="hidden md:flex h-full items-center px-2" href="/wiki/ヘルプ:ホーム">Wiki</a>
<a class="hidden md:flex h-full items-center px-2" href="/users">ユーザ</a>
</div>
<a href="#" class="md:hidden ml-auto pr-4
text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100">
Menu
</a>
</nav>
</div>
<!-- outlet -->
</div>
<div id="root"><!-- outlet --></div>
<!-- ssr-state -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5 -3
ファイルの表示
@@ -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",
+75
ファイルの表示
@@ -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 (/<title>[\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')
+47 -44
ファイルの表示
@@ -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
+2 -9
ファイルの表示
@@ -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)]
+2 -1
ファイルの表示
@@ -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)
+7 -2
ファイルの表示
@@ -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}</title>
<meta name="description" content={description.text} lang={description.language}/>
{canonical != null && <link rel="canonical" href={canonical}/>}
{children}
</Helmet>)
}
+79
ファイルの表示
@@ -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<RenderedPage | null> => {
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 (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}>
<I18nextProvider i18n={instance}>
<AppFrame>
<StaticRouterProvider router={router} context={context} hydrate={false}/>
</AppFrame>
</I18nextProvider>
</QueryClientProvider>
</HelmetProvider>)
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 (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
.replace (/\u2029/g, '\\u2029')
return { status, html, head, locale, bootstrap }
}
finally
{
client.clear ()
}
})
}
+5 -1
ファイルの表示
@@ -2,6 +2,7 @@ import axios from 'axios'
import toCamel from 'camelcase-keys'
import { API_BASE_URL } from '@/config'
import { currentRenderContext } from '@/lib/renderContext'
import type { AxiosError, AxiosRequestConfig } from 'axios'
@@ -21,8 +22,11 @@ const withUserCode = (opt?: Opt): AxiosRequestConfig => {
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 ?? { }) } }
}
+43 -1
ファイルの表示
@@ -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<number> => {
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,
+31 -25
ファイルの表示
@@ -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<string, unknown>): Record<string, unknown> => {
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<string, string | number> = { },
instance: I18n = i18n,
): { text: string; language: string } => {
const languages = new Set<string> ()
const visiting = new Set<string> ()
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')
{
+2 -1
ファイルの表示
@@ -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 => {
+12
ファイルの表示
@@ -0,0 +1,12 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import type { RenderContext } from '@/lib/renderContext'
const requests = new AsyncLocalStorage<RenderContext> ()
export const currentRenderContext = (): RenderContext | undefined => requests.getStore ()
export const withRenderContext = <T,> (context: RenderContext, render: () => T): T =>
requests.run (context, render)
+13
ファイルの表示
@@ -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 }
}
+2 -1
ファイルの表示
@@ -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 => {
+2 -1
ファイルの表示
@@ -10,9 +10,10 @@ import type { ClientBehaviorSettings } from '@/lib/settings'
export const useClientBehaviourSettings = (): ClientBehaviorSettings => {
const [settings, setSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useState<ClientBehaviorSettings> ({ })
useEffect (() => {
setSettings (getClientBehaviorSettings ())
const handleClientSettingsChange = () => {
setSettings (getClientBehaviorSettings ())
}
+3 -3
ファイルの表示
@@ -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<string, string | number>) => {
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<string, string | number>) => {
const result = message (key, values)
+2
ファイルの表示
@@ -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)
+34 -12
ファイルの表示
@@ -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 (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}>
<I18nextProvider i18n={i18n}>
<App/>
</I18nextProvider>
</QueryClientProvider>
</HelmetProvider>)
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 = (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}>
<I18nextProvider i18n={instance}>
<App/>
</I18nextProvider>
</QueryClientProvider>
</HelmetProvider>)
const root = document.getElementById ('root')!
if (bootstrap != null)
hydrateRoot (root, app)
else
createRoot (root).render (app)
applyClientAnimationMode (getClientAnimationMode ())
}
void start ()
+5 -3
ファイルの表示
@@ -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
+11 -1
ファイルの表示
@@ -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 <NotFound/>
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 (
<MainArea className="min-w-0">
<PageHead title={message ('articleTitle', { title: current?.title || identifier })}/>
<PageHead title={message ('articleTitle', { title: current?.title || identifier })}
canonical={canonical}
description={description == null ? undefined : {
text: description, language: language === 'ain' ? 'ain-Kana' : language ?? 'ja' }}/>
{requestedEdition && (
<div className="text-sm flex gap-3 items-center justify-center
+7 -1
ファイルの表示
@@ -22,8 +22,14 @@ if (typeof originalTransform === 'function')
}
export default defineConfig ({
plugins: [helpAssets (), mdxPlugin, react ()],
plugins: [{ name: 'request-render-context', enforce: 'pre',
resolveId (id, _importer, options) {
const contextPath = path.resolve (__dirname, './src/lib/renderContext')
if (options.ssr && (id === contextPath || id === `${contextPath}.ts`))
return path.resolve (__dirname, './src/lib/renderContext.server.ts')
} }, helpAssets (), mdxPlugin, react ()],
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
ssr: { noExternal: ['react-helmet-async'] },
server: { host: true,
port: 5173,
strictPort: true,