diff --git a/docs/commands.md b/docs/commands.md
index b267600..df7b4a8 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -37,3 +37,56 @@ npm run test:run
cd backend && bundle exec rspec
cd ../frontend && npm run test:run && npm run build && npm run lint
```
+
+### Help SSR in production
+
+Build both `dist/client` and `dist/server` with `npm run build`, deploy them
+together, and restart the Node process. `npm run dev` and `npm run preview`
+remain CSR servers; use `npm start` for SSR. The old sitemap generator is not
+part of this build.
+
+The following environment is a deployment proposal, not an applied VPS change:
+
+```sh
+cd frontend
+export NODE_ENV=production
+export HOST=127.0.0.1
+export PORT=5174
+export PUBLIC_ORIGIN=https://hub.nizika.monster
+export SSR_API_BASE_URL=http://127.0.0.1:3002
+npm start
+```
+
+Run this command through the VPS's process supervisor. Port 5174 avoids the
+Vite development port 5173. Rails remains on 127.0.0.1:3002. `PUBLIC_ORIGIN`
+must match the public HTTPS origin, including any non-default port; redirect
+alternate hostnames to that origin. `SSR_API_BASE_URL` configures server-side
+public API requests only. Keep the browser's existing API configuration pointed
+at the public `/api` endpoint.
+
+Inside the existing Apache HTTPS virtual host, with `proxy`, `proxy_http` and
+`headers` enabled, preserve the API rules before the catch-all frontend rule:
+
+```apache
+ProxyPreserveHost On
+RequestHeader set X-Forwarded-Proto "https"
+
+ProxyPass "/api/" "http://127.0.0.1:3002/"
+ProxyPassReverse "/api/" "http://127.0.0.1:3002/"
+
+ProxyPass "/" "http://127.0.0.1:5174/"
+ProxyPassReverse "/" "http://127.0.0.1:5174/"
+```
+
+Retain existing TLS, authentication-header handling and any exact `/api` rule.
+Replace the previous SPA catch-all rewrite instead of rewriting Help or missing
+URLs to `index.html` before they reach Node. Do not override Node's 404/503
+responses with an SPA fallback or cache its HTML. Hashed assets may be cached;
+deployments must keep old hashed files available for already-open clients.
+
+Only Help detail pages are server-rendered. Known CSR routes remain client
+rendered, old known routes redirect to Japanese URLs, and unknown routes/assets
+return 404. A failed Help preload keeps the readable server HTML, retries on
+online/focus/pageshow, and permits one automatic reload per bundle and URL in
+session storage. If storage is unavailable or the failure persists, the HTML
+remains intact; native links and manual reload remain available.
diff --git a/frontend/server.mjs b/frontend/server.mjs
index bbdb193..cc759e2 100644
--- a/frontend/server.mjs
+++ b/frontend/server.mjs
@@ -55,7 +55,15 @@ createServer (async (request, response) => {
return
}
const result = await render (url.pathname + url.search, origin, apiBaseURL)
- const html = result == null ? template : template
+ if (result?.location != null)
+ {
+ response.writeHead (result.status, { Location: result.location }).end ()
+ return
+ }
+ const html = result?.html == null
+ ? template.replace ('', result?.status === 404
+ ? ' ' : '')
+ : template
.replace ('lang="ja"', `lang="${result.locale}"`)
.replace (/
[\s\S]*?<\/title>/, '')
.replace (/ ]*>/, '')
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 23c82e3..3c62001 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,11 +4,10 @@ import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'r
import { useTranslation } from 'react-i18next'
import { createBrowserRouter,
Navigate,
- Route,
RouterProvider,
- Routes,
useLocation,
- useParams } from 'react-router-dom'
+ useParams,
+ useRoutes } from 'react-router-dom'
import DevModeWatermark from '@/components/DevModeWatermark'
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
@@ -38,6 +37,7 @@ import HelpDetailPage from '@/pages/help/HelpDetailPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
+import type { RouteObject } from 'react-router-dom'
import type { ClientAnimationMode } from '@/lib/settings'
import type { AvailableLocale } from '@/lib/locales'
@@ -92,51 +92,52 @@ const GekanatorRoute = ({ user }: { user: User | null }) => {
}
+export const pageRoutes = (
+ user: User | null,
+ setUser: Dispatch>,
+): RouteObject[] => [
+ { path: '', element: },
+ { path: 'posts', element: },
+ { path: 'posts/new', element: },
+ { path: 'posts/search', element: },
+ { path: 'posts/:id', element: },
+ { path: 'posts/changes', element: },
+ { path: 'tags', element: },
+ { path: 'tags/:id', element: },
+ { path: 'tags/:id/deerjikists', element: },
+ { path: 'tags/nico', element: },
+ { path: 'nico/tags', element: },
+ { path: 'tags/changes', element: },
+ { path: 'theatres/:id', element: },
+ { path: 'materials', element: , children: [
+ { index: true, element: },
+ { path: 'changes', element: },
+ { path: 'new', element: },
+ { path: 'suppressions', element: },
+ { path: ':id', element: }] },
+ { path: 'wiki', element: },
+ { path: 'wiki/:title', element: },
+ { path: 'wiki/new', element: },
+ { path: 'wiki/:id/edit', element: },
+ { path: 'wiki/:id/diff', element: },
+ { path: 'wiki/changes', element: },
+ { path: 'help', element: },
+ { path: 'help/changes', element: },
+ { path: 'help/:identifier/diff', element: },
+ { path: 'help/:identifier', element: },
+ { path: 'users/settings', element: },
+ { path: 'settings', element: },
+ { path: 'tos', element: },
+ { path: 'gekanator', element: },
+ { path: 'more', element: },
+ { path: '*', element: }]
+
+
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode
user: User | null
setUser: Dispatch> }) => {
- const location = useLocation ()
-
- const routes = (
-
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }>
- }/>
- }/>
- }/>
- }/>
- }/>
-
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- }/>
- )
+ const routes = useRoutes (pageRoutes (user, setUser))
return (
diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx
index 9da9bb6..f685398 100644
--- a/frontend/src/entry-server.tsx
+++ b/frontend/src/entry-server.tsx
@@ -2,13 +2,15 @@ import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-que
import { renderToString } from 'react-dom/server'
import { HelmetProvider } from 'react-helmet-async'
import { I18nextProvider } from 'react-i18next'
+import { matchRoutes } from 'react-router-dom'
import { createStaticHandler, createStaticRouter,
StaticRouterProvider } from 'react-router-dom/server'
-import { AppFrame, appRoutes } from '@/App'
+import { AppFrame, appRoutes, pageRoutes } from '@/App'
import { matchHelpDetail, prepareHelpPage } from '@/lib/help'
import { createUII18n } from '@/lib/i18n'
-import { fetchLocales, isLocaleCode } from '@/lib/locales'
+import { fetchLocales, isLocaleCode, legacyLocalePath,
+ unlocalisedPath } from '@/lib/locales'
import { localesKeys } from '@/lib/queryKeys'
import { withRenderContext } from '@/lib/renderContext.server'
@@ -21,17 +23,26 @@ type RenderedPage = {
locale: string
bootstrap: string }
+type RouteResponse = { status: 404 | 308; location?: string }
+
export const render = async (
address: string,
origin: string,
apiBaseURL: string,
-): Promise => {
+): Promise => {
const url = new URL (address, origin)
+ const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' })
+ const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
+ const segment = pathname.split ('/')[1]
+ const known = matchRoutes (pageRoutes (null, () => { })
+ .filter (route => route.path !== '*'), unlocalisedPath (pathname))
+ if (!(isLocaleCode (segment)) || known == null)
+ return { status: 404 }
+ if (legacy != null)
+ return { status: 308, location: legacy }
const match = matchHelpDetail (url.pathname)
- if (match == null)
- return null
- const locale = isLocaleCode (match[1]) ? match[1] : 'ja'
+ const locale = segment
return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => {
const client = new QueryClient ({ defaultOptions: {
queries: { retry: false, gcTime: Infinity } } })
@@ -39,9 +50,12 @@ export const render = async (
{
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 active = locales.locales.some (entry => entry.code === locale)
+ if (!(active))
+ return { status: 404 as const }
+ if (match == null || known.at (-1)?.route.path !== 'help/:identifier')
+ return null
+ const status = await prepareHelpPage (client, url)
const instance = createUII18n (locale)
const handler = createStaticHandler (appRoutes)
const context = await handler.query (new Request (url))
@@ -66,7 +80,8 @@ export const render = async (
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 })
+ const bootstrap = JSON.stringify ({ state, status, locale,
+ url: url.pathname + url.search })
.replace (/ {
- 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 mount = (serverRendered: boolean) => {
const instance = createUII18n (currentViewingLocale ())
const app = (
@@ -39,12 +33,82 @@ const start = async () => {
)
const root = document.getElementById ('root')!
- if (bootstrap != null)
+ if (serverRendered)
hydrateRoot (root, app)
else
createRoot (root).render (app)
- applyClientAnimationMode (getClientAnimationMode ())
}
-void start ()
+const start = () => {
+ applyClientAnimationMode (getClientAnimationMode ())
+ const bootstrap = document.getElementById ('ssr-state')
+ if (bootstrap == null)
+ {
+ mount (false)
+ return
+ }
+ const storageKey = 'btrc_hub.ssr-recovery'
+ const identity = `${import.meta.url}:${location.pathname}${location.search}`
+ let pending = false
+ let mounted = false
+ const retry = () => { void attempt () }
+ const attempt = async () => {
+ if (pending || mounted)
+ return
+ pending = true
+ try
+ {
+ const data = JSON.parse (bootstrap.textContent ?? '') as {
+ state: DehydratedState; status: number }
+ if (data?.state == null || !(Array.isArray (data.state.queries)))
+ throw new Error ('Invalid SSR query state')
+ hydrate (client, data.state)
+ if (client.getQueryData (localesKeys.index) == null)
+ throw new Error ('Missing SSR locales')
+ const status = await prepareHelpPage (client, new URL (window.location.href))
+ if (status !== data.status)
+ throw new Error ('Server and browser Help versions differ')
+ mount (true)
+ mounted = true
+ for (const event of ['online', 'focus', 'pageshow'])
+ window.removeEventListener (event, retry)
+ try
+ {
+ sessionStorage.removeItem (storageKey)
+ }
+ catch
+ {
+ // Storage may be unavailable; successful hydration needs no recovery marker.
+ }
+ }
+ catch (error)
+ {
+ console.error ('[SSR] Preparation failed; keeping server HTML', error)
+ // A new document also clears failed dynamic-import module caches.
+ // Never clear the root or attempt CSR over unmatched server markup.
+ try
+ {
+ if (navigator.onLine && sessionStorage.getItem (storageKey) !== identity)
+ {
+ sessionStorage.setItem (storageKey, identity)
+ window.location.reload ()
+ }
+ }
+ catch
+ {
+ // Keep the readable document and retry on online/focus/pageshow instead.
+ }
+ }
+ finally
+ {
+ pending = false
+ }
+ }
+ for (const event of ['online', 'focus', 'pageshow'])
+ window.addEventListener (event, retry)
+ retry ()
+}
+
+
+start ()
diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx
index e118b25..5370cae 100644
--- a/frontend/src/pages/tags/TagHistoryPage.tsx
+++ b/frontend/src/pages/tags/TagHistoryPage.tsx
@@ -19,7 +19,7 @@ import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagChanges } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUITranslation } from '@/lib/useUITranslation'
-import { cn, localisedDateString } from '@/lib/utils'
+import { localisedDateString } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
@@ -79,25 +79,23 @@ const renderPrimaryName = (
>)
-const renderNameSnapshot = (
- snapshot: TagNameSnapshot,
+const renderAliasDiff = (
+ previous: TagNameSnapshot | null,
+ current: TagNameSnapshot | null,
language: string,
- label: (key: string) => ReactNode,
-) => (
-
-
- {label ('name')}:
- {renderPrimaryName (snapshot, language, label)}
-
- {snapshot.aliases.length > 0 && (
-
- {label ('tags:aliases')}:
- {snapshot.aliases.map (alias => (
-
- {alias.name}
- ))}
-
)}
-
)
+) => {
+ const before = new Set (previous?.aliases.map (alias => alias.name) ?? [])
+ const after = new Set (current?.aliases.map (alias => alias.name) ?? [])
+ return [...new Set ([...after, ...before])].map (name => {
+ const content = (
+ {name} )
+ if (!(before.has (name)))
+ return {content}
+ if (!(after.has (name)))
+ return {content}
+ return {content}
+ })
+}
const renderDiff = (
@@ -120,7 +118,9 @@ const renderDiff = (
>)
-const TagVersionNames = ({ change }: { change: TagVersion }) => {
+const TagVersionRows = ({ change, children }: {
+ change: TagVersion
+ children: (primary: ReactNode, toggle: ReactNode) => ReactNode }) => {
const locale = useLocale ()
const language = localeLanguage (locale)
const { message, label } = useUITranslation ('history')
@@ -149,41 +149,52 @@ const TagVersionNames = ({ change }: { change: TagVersion }) => {
...nameChanges.map (entry => entry.languageCode)])]
.filter (code => code !== language).sort ()
const otherChanges = nameChanges.filter (entry => entry.languageCode !== language)
+ const rowIds = otherLanguages.map ((_, index) => `${panelId}-${index}`)
+ const primaryEqual = (prev: TagNameSnapshot | null, next: TagNameSnapshot | null) =>
+ prev?.name === next?.name && prev?.autoGenerated === next?.autoGenerated
+ const primary = renderDiff ({ current, prev: previous },
+ snapshot => renderPrimaryName (snapshot, language, label), primaryEqual)
+ const toggle = otherLanguages.length > 0 && (
+
+ setExpanded (value => !value)}>
+ {label ('nameChanges.otherLanguages')}
+ {otherChanges.length > 0 && label ('nameChanges.changed')}
+ {expanded ? '▲' : '▼'}
+
+
)
return (
- <>
- {renderDiff ({ current, prev: previous },
- snapshot => renderPrimaryName (snapshot, language, label),
- (prev, next) => prev?.name === next?.name
- && prev?.autoGenerated === next?.autoGenerated)}
- {otherLanguages.length > 0 && (
-
-
setExpanded (value => !value)}>
- {label ('nameChanges.otherLanguages')}
- {otherChanges.length > 0 && label ('nameChanges.changed')}
-
-
- {otherLanguages.map (code => {
- const entry = nameChanges.find (value => value.languageCode === code)
- const key = `languageNames.${code}`
- const known = i18n.exists (key, { ns: 'history', lng: locale })
- const snapshot = names[code]
- return (
-
-
- {known ? message (key).text : code}
-
- {entry != null
- ? renderDiff (entry,
- value => renderNameSnapshot (value, code, label))
- : snapshot != null && renderNameSnapshot (snapshot, code, label)}
-
)
- })}
-
-
)}
- >)
+
+ {children (primary, toggle)}
+ {otherLanguages.map ((code, index) => {
+ const entry = nameChanges.find (value => value.languageCode === code)
+ const key = `languageNames.${code}`
+ const known = i18n.exists (key, { ns: 'history', lng: locale })
+ const next = entry != null ? entry.current : names[code] ?? null
+ const prev = entry != null ? entry.prev : next
+ return (
+
+
+
+ {known ? message (key).text : code}
+
+
+
+ {renderDiff ({ current: next, prev },
+ value => renderPrimaryName (value, code, label), primaryEqual)}
+
+
+
+ {renderAliasDiff (prev, next, code)}
+
+
+
+ )
+ })}
+ )
}
@@ -261,136 +272,137 @@ const TagHistoryPage: FC = () => {
-
- {changes.map (change => (
-
- {change.tagId}.{change.versionNo}
-
-
-
-
- {renderDiff (change.category,
- category => label (`common:categories.${category}`))}
-
-
- {renderDiff ({
- current: change.deprecatedAt.current ? 'deprecated' : '',
- prev: change.deprecatedAt.prev ? 'deprecated' : '' },
- state => state ? label (`common:states.${state}`) : '')}
-
-
- {change.aliases.map ((tag, i) => (
- tag.type === 'added'
- ? (
-
- {tag.name}
- )
- : (
- tag.type === 'removed'
+ {changes.map (change => (
+
+ {(primary, toggle) => (
+
+ {change.tagId}.{change.versionNo}
+
+ {primary}
+ {toggle}
+
+
+ {renderDiff (change.category,
+ category => label (`common:categories.${category}`))}
+
+
+ {renderDiff ({
+ current: change.deprecatedAt.current ? 'deprecated' : '',
+ prev: change.deprecatedAt.prev ? 'deprecated' : '' },
+ state => state ? label (`common:states.${state}`) : '')}
+
+
+ {change.aliases.map ((tag, i) => (
+ tag.type === 'added'
? (
-
+ className="mr-2 text-green-600 dark:text-green-400">
{tag.name}
- )
+ )
: (
-
- {tag.name}
- ))))}
-
-
- {change.parentTags.map ((tag, i) => (
- tag.type === 'added'
- ? (
-
- {tag.tag.name}
- )
- : (
- tag.type === 'removed'
+ tag.type === 'removed'
+ ? (
+
+ {tag.name}
+ )
+ : (
+
+ {tag.name}
+ ))))}
+
+
+ {change.parentTags.map ((tag, i) => (
+ tag.type === 'added'
? (
-
+ className="mr-2 text-green-600 dark:text-green-400">
{tag.tag.name}
- )
+ )
: (
-
- {tag.tag.name}
- ))))}
-
-
- {change.createdByUser
- ? (
-
- {change.createdByUser.name
- || label ('anonymous', { id: change.createdByUser.id })}
- )
- : label ('bot')}
-
- {localisedDateString (change.createdAt, locale)}
-
-
- {
- e.preventDefault ()
-
- if (!(await dialogue.confirm ({
- title: label ('confirmTitle'),
- description: (
- <>
- {label ('confirmTag', {
- name: change.name.current ?? '', version: change.versionNo })}
-
- {label ('restoreScope')}
- >),
- confirmText: label ('revert'),
- cancelText: label ('settings:actions.cancel'),
- closeLabel: label ('settings:actions.close') })))
- return
-
- try
- {
- if (!(change.name.current)
- || change.parentTags.some (tag =>
- tag.type !== 'removed' && !(tag.tag.name)))
- throw new Error ()
- await apiPut (
- `/tags/${ change.tagId }`,
- { name: change.name.current,
- category: change.category.current,
- deprecated: change.deprecatedAt.current ? '1' : '0',
- aliases:
- change.aliases
- .filter (t => t.type !== 'removed')
- .map (t => t.name)
- .join (' '),
- parent_tags:
- change.parentTags
- .filter (t => t.type !== 'removed')
- .map (t => t.tag.name)
- .join (' ') }, { locale })
-
- qc.invalidateQueries ({ queryKey: postsKeys.root })
- qc.invalidateQueries ({ queryKey: tagsKeys.root })
- qc.invalidateQueries ({ queryKey: wikiKeys.root })
- toast ({ description: label ('restored') })
- }
- catch
- {
- toast ({ description: label ('restoreFailed') })
- }
- }}>
- {label ('shared:actions.restore')}
-
-
- ))}
-
+ tag.type === 'removed'
+ ? (
+
+ {tag.tag.name}
+ )
+ : (
+
+ {tag.tag.name}
+ ))))}
+
+
+ {change.createdByUser
+ ? (
+
+ {change.createdByUser.name
+ || label ('anonymous', { id: change.createdByUser.id })}
+ )
+ : label ('bot')}
+
+ {localisedDateString (change.createdAt, locale)}
+
+
+ {
+ e.preventDefault ()
+
+ if (!(await dialogue.confirm ({
+ title: label ('confirmTitle'),
+ description: (
+ <>
+ {label ('confirmTag', {
+ name: change.name.current ?? '', version: change.versionNo })}
+
+ {label ('restoreScope')}
+ >),
+ confirmText: label ('revert'),
+ cancelText: label ('settings:actions.cancel'),
+ closeLabel: label ('settings:actions.close') })))
+ return
+
+ try
+ {
+ if (!(change.name.current)
+ || change.parentTags.some (tag =>
+ tag.type !== 'removed' && !(tag.tag.name)))
+ throw new Error ()
+ await apiPut (
+ `/tags/${ change.tagId }`,
+ { name: change.name.current,
+ category: change.category.current,
+ deprecated: change.deprecatedAt.current ? '1' : '0',
+ aliases:
+ change.aliases
+ .filter (t => t.type !== 'removed')
+ .map (t => t.name)
+ .join (' '),
+ parent_tags:
+ change.parentTags
+ .filter (t => t.type !== 'removed')
+ .map (t => t.tag.name)
+ .join (' ') }, { locale })
+
+ qc.invalidateQueries ({ queryKey: postsKeys.root })
+ qc.invalidateQueries ({ queryKey: tagsKeys.root })
+ qc.invalidateQueries ({ queryKey: wikiKeys.root })
+ toast ({ description: label ('restored') })
+ }
+ catch
+ {
+ toast ({ description: label ('restoreFailed') })
+ }
+ }}>
+ {label ('shared:actions.restore')}
+
+
+ )}
+ ))}