このコミットが含まれているのは:
+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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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 { 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')
|
||||
{
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 =>
|
||||
safeParseClientSettings (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY))
|
||||
safeParseClientSettings (typeof window === 'undefined'
|
||||
? null : localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY))
|
||||
|
||||
|
||||
export const saveClientSettings = (settings: ClientSettings): void => {
|
||||
|
||||
@@ -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 ())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする