このコミットが含まれているのは:
+2
-24
@@ -9,30 +9,8 @@
|
|||||||
<!-- helmet -->
|
<!-- helmet -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root">
|
<div id="root"><!-- outlet --></div>
|
||||||
<div class="flex flex-col h-screen w-screen">
|
<!-- ssr-state -->
|
||||||
<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>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+5
-3
@@ -5,12 +5,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && npm run build:client && npm run build:server",
|
||||||
"postbuild": "node scripts/generate-sitemap.js",
|
"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 .",
|
"lint": "eslint .",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview --outDir dist/client"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@@ -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 { useQuery } from '@tanstack/react-query'
|
||||||
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import { createBrowserRouter,
|
import { createBrowserRouter,
|
||||||
Navigate,
|
Navigate,
|
||||||
@@ -33,47 +33,48 @@ import { applyClientAnimationMode,
|
|||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
|
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
|
||||||
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
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 NotFound from '@/pages/NotFound'
|
||||||
import TOSPage from '@/pages/TOSPage'
|
|
||||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
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 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 { ClientAnimationMode } from '@/lib/settings'
|
||||||
import type { AvailableLocale } from '@/lib/locales'
|
import type { AvailableLocale } from '@/lib/locales'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
const GekanatorPage = lazy (() => import ('@/pages/GekanatorPage'))
|
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 }) => {
|
const GekanatorRoute = ({ user }: { user: User | null }) => {
|
||||||
@@ -137,12 +138,9 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
|||||||
<Route path="*" element={<NotFound/>}/>
|
<Route path="*" element={<NotFound/>}/>
|
||||||
</Routes>)
|
</Routes>)
|
||||||
|
|
||||||
if (animationMode === 'off')
|
|
||||||
return routes
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait" initial={animationMode !== 'off'}>
|
||||||
{routes}
|
<Suspense fallback={null}>{routes}</Suspense>
|
||||||
</AnimatePresence>)
|
</AnimatePresence>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +157,6 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
const [status, setStatus] = useState (200)
|
const [status, setStatus] = useState (200)
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
|
|
||||||
|
|
||||||
const appLayoutTransition = useMemo (
|
const appLayoutTransition = useMemo (
|
||||||
() => (
|
() => (
|
||||||
@@ -265,7 +262,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
? 'user'
|
? 'user'
|
||||||
: 'always'
|
: 'always'
|
||||||
}>
|
}>
|
||||||
<LayoutWrapper>
|
<LayoutGroup>
|
||||||
<motion.div
|
<motion.div
|
||||||
layout={animationMode === 'off' ? false : 'position'}
|
layout={animationMode === 'off' ? false : 'position'}
|
||||||
transition={{ layout: appLayoutTransition }}
|
transition={{ layout: appLayoutTransition }}
|
||||||
@@ -276,7 +273,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
user={user}
|
user={user}
|
||||||
setUser={setUser}/>
|
setUser={setUser}/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</LayoutWrapper>
|
</LayoutGroup>
|
||||||
</MotionConfig>
|
</MotionConfig>
|
||||||
|
|
||||||
<Toaster/>
|
<Toaster/>
|
||||||
@@ -339,17 +336,23 @@ const LocaleRedirect = ({ to }: { to: string }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const router = createBrowserRouter ([
|
export const appRoutes = [
|
||||||
{ path: '/:locale/*', element: <LocaleRoute/> },
|
{ path: '/:locale/*', element: <LocaleRoute/> },
|
||||||
{ path: '*', element: <LocaleRoute/> }])
|
{ path: '*', element: <LocaleRoute/> }]
|
||||||
|
|
||||||
|
|
||||||
const App: FC = () => (
|
export const AppFrame = ({ children }: { children: ReactNode }) => (
|
||||||
<>
|
<>
|
||||||
<RouteBlockerOverlay/>
|
<RouteBlockerOverlay/>
|
||||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
{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
|
export default App
|
||||||
|
|||||||
@@ -27,20 +27,13 @@ const ErrorScreen: FC<Props> = ({ status }) => {
|
|||||||
|
|
||||||
const title = message.replaceAll ('(', ' (').replaceAll (')', ')')
|
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 (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<PageHead title={{ text: title,
|
<PageHead title={{ text: title,
|
||||||
language: translate (`${status}.message`).language }}>
|
language: translate (`${status}.message`).language }}>
|
||||||
<meta name="robots" content="noindex"/>
|
<meta name="robots" content="noindex"/>
|
||||||
|
<style>{`@font-face { font-family: 'Nikumaru';
|
||||||
|
src: url(${nikumaru}) format('opentype'); }`}</style>
|
||||||
</PageHead>
|
</PageHead>
|
||||||
<div className="text-6xl font-bold text-transparent
|
<div className="text-6xl font-bold text-transparent
|
||||||
bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)]
|
bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
|||||||
import { prefetchForURL } from '@/lib/prefetchers'
|
import { prefetchForURL } from '@/lib/prefetchers'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { currentRenderContext } from '@/lib/renderContext'
|
||||||
|
|
||||||
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
|
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
|
||||||
import type { To } from 'react-router-dom'
|
import type { To } from 'react-router-dom'
|
||||||
@@ -47,7 +48,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
|||||||
[to, locale],
|
[to, locale],
|
||||||
)
|
)
|
||||||
const url = useMemo (() => {
|
const url = useMemo (() => {
|
||||||
return (new URL (path, window.location.origin)).toString ()
|
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
|
||||||
}, [path])
|
}, [path])
|
||||||
const setOverlay = useOverlayStore (s => s.setActive)
|
const setOverlay = useOverlayStore (s => s.setActive)
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ type LocalisedTitle = { text: string; language: string }
|
|||||||
type Props = {
|
type Props = {
|
||||||
title?: string | LocalisedTitle
|
title?: string | LocalisedTitle
|
||||||
includeSiteName?: boolean
|
includeSiteName?: boolean
|
||||||
|
description?: LocalisedTitle
|
||||||
|
canonical?: string
|
||||||
children?: ReactNode }
|
children?: ReactNode }
|
||||||
|
|
||||||
|
|
||||||
const PageHead = ({ title, includeSiteName = true, children }: Props) => {
|
const PageHead = (
|
||||||
|
{ title, includeSiteName = true, description: pageDescription, canonical, children }: Props,
|
||||||
|
) => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const { message, siteTitle } = useUITranslation ('shared')
|
const { message, siteTitle } = useUITranslation ('shared')
|
||||||
const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title
|
const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title
|
||||||
@@ -20,12 +24,13 @@ const PageHead = ({ title, includeSiteName = true, children }: Props) => {
|
|||||||
pageTitle == null
|
pageTitle == null
|
||||||
? siteTitle.text
|
? siteTitle.text
|
||||||
: (includeSiteName ? `${pageTitle.text} | ${siteTitle.text}` : pageTitle.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 (
|
return (
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle}</title>
|
<title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle}</title>
|
||||||
<meta name="description" content={description.text} lang={description.language}/>
|
<meta name="description" content={description.text} lang={description.language}/>
|
||||||
|
{canonical != null && <link rel="canonical" href={canonical}/>}
|
||||||
{children}
|
{children}
|
||||||
</Helmet>)
|
</Helmet>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import axios from 'axios'
|
|||||||
import toCamel from 'camelcase-keys'
|
import toCamel from 'camelcase-keys'
|
||||||
|
|
||||||
import { API_BASE_URL } from '@/config'
|
import { API_BASE_URL } from '@/config'
|
||||||
|
import { currentRenderContext } from '@/lib/renderContext'
|
||||||
|
|
||||||
import type { AxiosError, AxiosRequestConfig } from 'axios'
|
import type { AxiosError, AxiosRequestConfig } from 'axios'
|
||||||
|
|
||||||
@@ -21,8 +22,11 @@ const withUserCode = (opt?: Opt): AxiosRequestConfig => {
|
|||||||
const { locale, ...config } = opt ?? { }
|
const { locale, ...config } = opt ?? { }
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
|
...(currentRenderContext ()?.apiBaseURL != null
|
||||||
|
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
|
||||||
...(locale != null && { params: { locale, ...config.params } }),
|
...(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 ?? { }) } }
|
...(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'
|
import type { ComponentType } from 'react'
|
||||||
|
|
||||||
type ArticleLanguage = { currentEdition: string; publishedEditions: string[] }
|
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 => {
|
export const helpEditionDate = (edition: string): Date | null => {
|
||||||
const parsed = parseHelpEdition (edition)
|
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
|
: 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 (
|
export const getHelpEditions = async (
|
||||||
identifier: string,
|
identifier: string,
|
||||||
language: string,
|
language: string,
|
||||||
|
|||||||
+16
-10
@@ -1,13 +1,10 @@
|
|||||||
import { createInstance } from 'i18next'
|
import { createInstance } from 'i18next'
|
||||||
import { initReactI18next } from 'react-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'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
export const i18n = createInstance ()
|
|
||||||
|
|
||||||
|
|
||||||
// Keep Japanese placeholders editable without reporting them as completed translations.
|
// Keep Japanese placeholders editable without reporting them as completed translations.
|
||||||
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
|
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
|
||||||
const untranslated = new Set (
|
const untranslated = new Set (
|
||||||
@@ -42,8 +39,10 @@ for (const [path, dictionary] of Object.entries (dictionaries))
|
|||||||
const namespaces = Object.keys (resources.ja ?? { })
|
const namespaces = Object.keys (resources.ja ?? { })
|
||||||
|
|
||||||
|
|
||||||
void i18n.use (initReactI18next).init ({
|
export const createUII18n = (locale: string): I18n => {
|
||||||
lng: 'ja',
|
const instance = createInstance ()
|
||||||
|
void instance.init ({
|
||||||
|
lng: locale,
|
||||||
fallbackLng: 'ja',
|
fallbackLng: 'ja',
|
||||||
supportedLngs: ['ja', 'ain-Kana'],
|
supportedLngs: ['ja', 'ain-Kana'],
|
||||||
load: 'currentOnly',
|
load: 'currentOnly',
|
||||||
@@ -59,6 +58,12 @@ void i18n.use (initReactI18next).init ({
|
|||||||
console.warn ('[i18n] missing key', { languages, namespace, key })
|
console.warn ('[i18n] missing key', { languages, namespace, key })
|
||||||
},
|
},
|
||||||
react: { useSuspense: false } })
|
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 = (
|
export const translateUIMessage = (
|
||||||
@@ -66,19 +71,20 @@ export const translateUIMessage = (
|
|||||||
namespace: string,
|
namespace: string,
|
||||||
locale: string,
|
locale: string,
|
||||||
values: Record<string, string | number> = { },
|
values: Record<string, string | number> = { },
|
||||||
|
instance: I18n = i18n,
|
||||||
): { text: string; language: string } => {
|
): { text: string; language: string } => {
|
||||||
const languages = new Set<string> ()
|
const languages = new Set<string> ()
|
||||||
const visiting = new Set<string> ()
|
const visiting = new Set<string> ()
|
||||||
const collectLanguages = (reference: string, options: TOptions): 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 })
|
returnDetails: true, returnObjects: false, nest: false, skipInterpolation: true })
|
||||||
const identity = `${details.usedLng}:${details.usedNS}:${details.exactUsedKey}`
|
const identity = `${details.usedLng}:${details.usedNS}:${details.exactUsedKey}`
|
||||||
if (visiting.has (identity) || typeof details.res !== 'string')
|
if (visiting.has (identity) || typeof details.res !== 'string')
|
||||||
return details.usedLng
|
return details.usedLng
|
||||||
visiting.add (identity)
|
visiting.add (identity)
|
||||||
const nestingOptions = { ...i18n.options.interpolation, ...options,
|
const nestingOptions = { ...instance.options.interpolation, ...options,
|
||||||
ns: details.usedNS, lng: details.usedLng }
|
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) => {
|
(nestedKey: string, nestedOptions: TOptions) => {
|
||||||
collectLanguages (nestedKey, nestedOptions)
|
collectLanguages (nestedKey, nestedOptions)
|
||||||
return ' '
|
return ' '
|
||||||
@@ -94,7 +100,7 @@ export const translateUIMessage = (
|
|||||||
// A single lang attribute cannot describe mixed fallback text: use the full Japanese message.
|
// A single lang attribute cannot describe mixed fallback text: use the full Japanese message.
|
||||||
const mixed = languages.size > 1
|
const mixed = languages.size > 1
|
||||||
const language = mixed ? 'ja' : languages.values ().next ().value ?? resolvedLanguage
|
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 })
|
returnDetails: false, returnObjects: false })
|
||||||
if (typeof text !== 'string')
|
if (typeof text !== 'string')
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
import { currentRenderContext } from '@/lib/renderContext'
|
||||||
|
|
||||||
import type { To } from 'react-router-dom'
|
import type { To } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export const resolveViewingLocale = (
|
|||||||
|
|
||||||
|
|
||||||
export const currentViewingLocale = (): LocaleCode =>
|
export const currentViewingLocale = (): LocaleCode =>
|
||||||
resolveViewingLocale (typeof window === 'undefined' ? '' : window.location.pathname)
|
resolveViewingLocale (currentRenderContext ()?.pathname ?? '')
|
||||||
|
|
||||||
|
|
||||||
export const unlocalisedPath = (pathname: string): string => {
|
export const unlocalisedPath = (pathname: string): string => {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -347,7 +347,8 @@ const safeParseClientSettings = (raw: string | null): ClientSettings => {
|
|||||||
|
|
||||||
|
|
||||||
export const loadClientSettings = (): 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 => {
|
export const saveClientSettings = (settings: ClientSettings): void => {
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import type { ClientBehaviorSettings } from '@/lib/settings'
|
|||||||
|
|
||||||
export const useClientBehaviourSettings = (): ClientBehaviorSettings => {
|
export const useClientBehaviourSettings = (): ClientBehaviorSettings => {
|
||||||
const [settings, setSettings] =
|
const [settings, setSettings] =
|
||||||
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
|
useState<ClientBehaviorSettings> ({ })
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
setSettings (getClientBehaviorSettings ())
|
||||||
const handleClientSettingsChange = () => {
|
const handleClientSettingsChange = () => {
|
||||||
setSettings (getClientBehaviorSettings ())
|
setSettings (getClientBehaviorSettings ())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { useLocale } from '@/lib/useLocale'
|
|||||||
|
|
||||||
export const useUITranslation = (namespace = 'common') => {
|
export const useUITranslation = (namespace = 'common') => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
useTranslation (namespace, { lng: locale })
|
const { i18n } = useTranslation (namespace, { lng: locale })
|
||||||
|
|
||||||
const message = useCallback ((key: string, values?: Record<string, string | number>) => {
|
const message = useCallback ((key: string, values?: Record<string, string | number>) => {
|
||||||
return translateUIMessage (key, namespace, locale, values)
|
return translateUIMessage (key, namespace, locale, values, i18n)
|
||||||
}, [namespace, locale])
|
}, [namespace, locale, i18n])
|
||||||
|
|
||||||
const label = useCallback ((key: string, values?: Record<string, string | number>) => {
|
const label = useCallback ((key: string, values?: Record<string, string | number>) => {
|
||||||
const result = message (key, values)
|
const result = message (key, values)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const formatDateString = (
|
|||||||
{
|
{
|
||||||
dateFormatters.set (unknown, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', {
|
dateFormatters.set (unknown, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', {
|
||||||
era: 'long', year: 'numeric',
|
era: 'long', year: 'numeric',
|
||||||
|
timeZone: 'Asia/Tokyo',
|
||||||
month: unknown === 'month' ? undefined : 'long',
|
month: unknown === 'month' ? undefined : 'long',
|
||||||
day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric',
|
day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric',
|
||||||
weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short',
|
weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short',
|
||||||
@@ -45,6 +46,7 @@ const formatDateString = (
|
|||||||
}
|
}
|
||||||
ainuDateFormatter ??= new Intl.DateTimeFormat ('en-CA', {
|
ainuDateFormatter ??= new Intl.DateTimeFormat ('en-CA', {
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
timeZone: 'Asia/Tokyo',
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
hourCycle: 'h23' })
|
hourCycle: 'h23' })
|
||||||
const parts = ainuDateFormatter.formatToParts (date)
|
const parts = ainuDateFormatter.formatToParts (date)
|
||||||
|
|||||||
+28
-6
@@ -1,13 +1,17 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider, hydrate } from '@tanstack/react-query'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot, hydrateRoot } from 'react-dom/client'
|
||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
import { I18nextProvider } from 'react-i18next'
|
import { I18nextProvider } from 'react-i18next'
|
||||||
|
|
||||||
import '@/index.css'
|
import '@/index.css'
|
||||||
import App from '@/App'
|
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 { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
|
||||||
|
|
||||||
|
import type { DehydratedState } from '@tanstack/react-query'
|
||||||
|
|
||||||
const helmetContext = { }
|
const helmetContext = { }
|
||||||
|
|
||||||
const client = new QueryClient ({
|
const client = new QueryClient ({
|
||||||
@@ -16,13 +20,31 @@ const client = new QueryClient ({
|
|||||||
gcTime: 30 * 60 * 1000,
|
gcTime: 30 * 60 * 1000,
|
||||||
retry: 1 } } })
|
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 = (
|
||||||
<HelmetProvider context={helmetContext}>
|
<HelmetProvider context={helmetContext}>
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={instance}>
|
||||||
<App/>
|
<App/>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</HelmetProvider>)
|
</HelmetProvider>)
|
||||||
|
const root = document.getElementById ('root')!
|
||||||
|
if (bootstrap != null)
|
||||||
|
hydrateRoot (root, app)
|
||||||
|
else
|
||||||
|
createRoot (root).render (app)
|
||||||
|
applyClientAnimationMode (getClientAnimationMode ())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void start ()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useLocation } from 'react-router-dom'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import { localiseUIChildren } from '@/components/LocalisedText'
|
import { localiseUIChildren } from '@/components/LocalisedText'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { currentRenderContext } from '@/lib/renderContext'
|
||||||
|
|
||||||
import type { MDXComponents } from 'mdx/types'
|
import type { MDXComponents } from 'mdx/types'
|
||||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react'
|
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react'
|
||||||
@@ -30,19 +31,20 @@ const MDXLink = (
|
|||||||
{ href, children, ...props }: ComponentProps<'a'>,
|
{ href, children, ...props }: ComponentProps<'a'>,
|
||||||
) => {
|
) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
const origin = currentRenderContext ()?.origin ?? 'http://localhost'
|
||||||
const url = useMemo (() => {
|
const url = useMemo (() => {
|
||||||
try
|
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
|
return href ? new URL (href, base) : null
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}, [href, location.pathname, location.search])
|
}, [href, location.pathname, location.search, origin])
|
||||||
const internal = url != null
|
const internal = url != null
|
||||||
&& url.origin === window.location.origin
|
&& url.origin === origin
|
||||||
&& (url.protocol === 'http:' || url.protocol === 'https:')
|
&& (url.protocol === 'http:' || url.protocol === 'https:')
|
||||||
const anchor = href?.startsWith ('#')
|
const anchor = href?.startsWith ('#')
|
||||||
|| (url?.pathname === location.pathname
|
|| (url?.pathname === location.pathname
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useLocale } from '@/lib/useLocale'
|
|||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { dateString } from '@/lib/utils'
|
import { dateString } from '@/lib/utils'
|
||||||
import { helpKeys } from '@/lib/queryKeys'
|
import { helpKeys } from '@/lib/queryKeys'
|
||||||
|
import { currentRenderContext } from '@/lib/renderContext'
|
||||||
import { LocalisedDocument } from '@/mdx-components'
|
import { LocalisedDocument } from '@/mdx-components'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
|
|
||||||
@@ -70,10 +71,19 @@ const HelpDetailPage: FC = () => {
|
|||||||
return <NotFound/>
|
return <NotFound/>
|
||||||
|
|
||||||
const Content = current?.content
|
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 (
|
return (
|
||||||
<MainArea className="min-w-0">
|
<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 && (
|
{requestedEdition && (
|
||||||
<div className="text-sm flex gap-3 items-center justify-center
|
<div className="text-sm flex gap-3 items-center justify-center
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ if (typeof originalTransform === 'function')
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig ({
|
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') } },
|
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
|
||||||
|
ssr: { noExternal: ['react-helmet-async'] },
|
||||||
server: { host: true,
|
server: { host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする