From 8bcc61c0f80a484ff0552d7b57707cf44398bb66 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 18 Oct 2026 23:26:55 +0900 Subject: [PATCH] #428 --- frontend/src/App.tsx | 30 ++++++++++++++------ frontend/src/components/PostEditForm.tsx | 7 +++-- frontend/src/components/ui/use-toast.tsx | 2 +- frontend/src/entry-server.tsx | 17 +++++------- frontend/src/lib/help.ts | 32 +++++++--------------- frontend/src/lib/prefetchers.ts | 4 +-- frontend/src/lib/routeLocales.ts | 22 +++++++++------ frontend/src/locales/ain-Kana/history.json | 2 +- frontend/src/locales/ja/history.json | 2 +- frontend/src/main.tsx | 11 ++++++-- frontend/src/pages/tags/TagHistoryPage.tsx | 9 +++--- 11 files changed, 75 insertions(+), 63 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3c62001..41a36b3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motio import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { createBrowserRouter, + matchRoutes, Navigate, RouterProvider, useLocation, @@ -16,9 +17,10 @@ import DialogueProvider from '@/components/dialogues/DialogueProvider' import PageHead from '@/components/common/PageHead' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' -import { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath } from '@/lib/locales' +import { prepareHelpPage } from '@/lib/help' +import { fetchLocales, isLocaleCode, legacyLocalePath, + switchLocalePath, unlocalisedPath } from '@/lib/locales' import { localesKeys } from '@/lib/queryKeys' -import { requiredPageLocale } from '@/lib/routeLocales' import { useLocale } from '@/lib/useLocale' import { applyClientAnimationMode, applyClientAppearance, @@ -37,10 +39,11 @@ 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 { RouteMatch } from 'react-router-dom' import type { ClientAnimationMode } from '@/lib/settings' import type { AvailableLocale } from '@/lib/locales' +import type { PageRoute } from '@/lib/routeLocales' import type { User } from '@/types' const GekanatorPage = lazy (() => import ('@/pages/GekanatorPage')) @@ -80,7 +83,7 @@ const WikiSearchPage = lazy (() => import ('@/pages/wiki/WikiSearchPage')) const GekanatorRoute = ({ user }: { user: User | null }) => { const locale = useLocale () const location = useLocation () - const pageLocale = requiredPageLocale (location.pathname) + const pageLocale = resolvePageRoute (location.pathname)?.route.handle?.locale if (pageLocale != null && pageLocale !== locale) return @@ -95,7 +98,7 @@ const GekanatorRoute = ({ user }: { user: User | null }) => { export const pageRoutes = ( user: User | null, setUser: Dispatch>, -): RouteObject[] => [ +): PageRoute[] => [ { path: '', element: }, { path: 'posts', element: }, { path: 'posts/new', element: }, @@ -124,13 +127,24 @@ export const pageRoutes = ( { path: 'help', element: }, { path: 'help/changes', element: }, { path: 'help/:identifier/diff', element: }, - { path: 'help/:identifier', element: }, + { id: 'help.detail', path: 'help/:identifier', element: , + handle: { ssr: prepareHelpPage } }, { path: 'users/settings', element: }, { path: 'settings', element: }, { path: 'tos', element: }, - { path: 'gekanator', element: }, + { id: 'gekanator', path: 'gekanator', element: , + handle: { locale: 'ja' } }, { path: 'more', element: }, - { path: '*', element: }] + { path: '*', element: , handle: { notFound: true } }] + + +export const resolvePageRoute = ( + pathname: string, +): RouteMatch | null => { + const match = matchRoutes ( + pageRoutes (null, () => { }), unlocalisedPath (pathname))?.at (-1) + return (match == null || match.route.handle?.notFound) ? null : match +} const RouteTransitionWrapper = ({ animationMode, user, setUser }: { diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 7c84726..57c6593 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -74,12 +74,15 @@ const PostEditForm: FC = ({ post, onSave }) => { const dialogue = useDialogue () - const update = async (...args: Parameters) => { + const update = async ( + postData: Parameters[0], + options: Parameters[1], + ) => { clearValidationErrors () try { - const data = await updatePost (...args, undefined, locale) + const data = await updatePost (postData, options, locale) onSave ({ ...post, versionNo: data.versionNo, title: data.title, diff --git a/frontend/src/components/ui/use-toast.tsx b/frontend/src/components/ui/use-toast.tsx index dcfbd7d..0762e80 100644 --- a/frontend/src/components/ui/use-toast.tsx +++ b/frontend/src/components/ui/use-toast.tsx @@ -11,7 +11,7 @@ import type { const TOAST_LIMIT = 1 const TOAST_REMOVE_DELAY = 1000000 -type ToasterToast = ToastProps & { +type ToasterToast = Omit & { id: string title?: React.ReactNode description?: React.ReactNode diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx index f685398..8c4e28e 100644 --- a/frontend/src/entry-server.tsx +++ b/frontend/src/entry-server.tsx @@ -2,15 +2,12 @@ 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, pageRoutes } from '@/App' -import { matchHelpDetail, prepareHelpPage } from '@/lib/help' +import { AppFrame, appRoutes, resolvePageRoute } from '@/App' import { createUII18n } from '@/lib/i18n' -import { fetchLocales, isLocaleCode, legacyLocalePath, - unlocalisedPath } from '@/lib/locales' +import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales' import { localesKeys } from '@/lib/queryKeys' import { withRenderContext } from '@/lib/renderContext.server' @@ -35,13 +32,11 @@ export const render = async ( 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)) + const known = resolvePageRoute (pathname) if (!(isLocaleCode (segment)) || known == null) return { status: 404 } if (legacy != null) return { status: 308, location: legacy } - const match = matchHelpDetail (url.pathname) const locale = segment return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => { const client = new QueryClient ({ defaultOptions: { @@ -53,9 +48,11 @@ export const render = async ( 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') + const prepare = known.route.handle?.ssr + if (prepare == null) return null - const status = await prepareHelpPage (client, url) + const status = await prepare ({ client, params: known.params, locale, + searchParams: url.searchParams }) const instance = createUII18n (locale) const handler = createStaticHandler (appRoutes) const context = await handler.query (new Request (url)) diff --git a/frontend/src/lib/help.ts b/frontend/src/lib/help.ts index 06128a2..e594085 100644 --- a/frontend/src/lib/help.ts +++ b/frontend/src/lib/help.ts @@ -1,9 +1,10 @@ -import { isLocaleCode, localeLanguage } from '@/lib/locales' +import { localeLanguage } from '@/lib/locales' import { helpKeys } from '@/lib/queryKeys' -import type { QueryClient } from '@tanstack/react-query' import type { ComponentType } from 'react' +import type { PagePreparation } from '@/lib/routeLocales' + type ArticleLanguage = { currentEdition: string; publishedEditions: string[] } export type ArticleMeta = { @@ -97,34 +98,21 @@ export const resolveHelpLanguage = ( : null -export const matchHelpDetail = (pathname: string): RegExpMatchArray | null => { - const matched = pathname.match (/^\/([^/]+)\/help\/([^/]+)\/?$/) - return matched?.[2] === 'changes' ? null : matched -} - - // MDX functions stay in memory; both entries load them before rendering. -export const prepareHelpPage = async (client: QueryClient, url: URL): Promise => { - const matched = matchHelpDetail (url.pathname) - if (matched == null || !(isLocaleCode (matched[1]))) +export const prepareHelpPage = async ( + { client, params, locale, searchParams }: PagePreparation, +): Promise => { + const identifier = params.identifier + if (identifier == null) 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])) + const language = resolveHelpLanguage (article, localeLanguage (locale)) if (language == null) return 404 const meta = article!.languages[language] - const edition = url.searchParams.get ('edition') ?? meta.currentEdition + const edition = searchParams.get ('edition') ?? meta.currentEdition if (!(meta.publishedEditions.includes (edition)) || parseHelpEdition (edition) == null) return 404 const content = await client.fetchQuery ({ diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 4bb563d..e78e3d1 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -31,7 +31,7 @@ const boolFromQuery = (value: string | null): boolean => ['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ()) -const prefetchHelpIndex: Prefetcher = async (qc, url, locale) => { +const prefetchHelpIndex: Prefetcher = async (qc, _url, locale) => { await qc.prefetchQuery ({ queryKey: helpKeys.index (localeLanguage (locale)), queryFn: () => getHelpSearchEntries (localeLanguage (locale)), @@ -63,7 +63,7 @@ const prefetchHelpShow: Prefetcher = async (qc, url, locale) => { } -const prefetchHelpChanges: Prefetcher = async (qc, url, locale) => { +const prefetchHelpChanges: Prefetcher = async (qc, _url, locale) => { await qc.prefetchQuery ({ queryKey: helpKeys.changes (localeLanguage (locale)), queryFn: () => getHelpChanges (localeLanguage (locale)), diff --git a/frontend/src/lib/routeLocales.ts b/frontend/src/lib/routeLocales.ts index b4f5337..7775314 100644 --- a/frontend/src/lib/routeLocales.ts +++ b/frontend/src/lib/routeLocales.ts @@ -1,13 +1,19 @@ -import { matchPath } from 'react-router-dom' - -import { unlocalisedPath } from '@/lib/locales' +import type { QueryClient } from '@tanstack/react-query' +import type { IndexRouteObject, NonIndexRouteObject, Params } from 'react-router-dom' import type { LocaleCode } from '@/lib/locales' -const PAGE_LOCALES: { path: string; locale: LocaleCode }[] = [ - { path: '/gekanator', locale: 'ja' }] +export type PagePreparation = { + client: QueryClient + params: Readonly + locale: LocaleCode + searchParams: URLSearchParams } +export type PageRouteHandle = { + ssr?: (request: PagePreparation) => Promise + locale?: LocaleCode + notFound?: boolean } -export const requiredPageLocale = (pathname: string): LocaleCode | null => - PAGE_LOCALES.find (page => - matchPath ({ path: page.path, end: true }, unlocalisedPath (pathname)))?.locale ?? null +export type PageRoute = { handle?: PageRouteHandle } & ( + Omit + | (Omit & { children?: PageRoute[] })) diff --git a/frontend/src/locales/ain-Kana/history.json b/frontend/src/locales/ain-Kana/history.json index 4ba959c..7170554 100644 --- a/frontend/src/locales/ain-Kana/history.json +++ b/frontend/src/locales/ain-Kana/history.json @@ -41,7 +41,7 @@ "certified": "公証済み" }, "languageNames": { - "ja": "日本語", + "ja": "シサㇺ イタㇰ", "ain": "ヤウンモシルン アイヌ イタㇰ" }, "revert": "アホシピレ", diff --git a/frontend/src/locales/ja/history.json b/frontend/src/locales/ja/history.json index 3d79308..b61935b 100644 --- a/frontend/src/locales/ja/history.json +++ b/frontend/src/locales/ja/history.json @@ -31,7 +31,7 @@ }, "languageNames": { "ja": "日本語", - "ain": "ヤウンモシルン アイヌ イタㇰ" + "ain": "北海道アイヌ語" }, "revert": "差戻", "restored": "差戻しました.", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ef50446..61b2186 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,9 +4,8 @@ import { HelmetProvider } from 'react-helmet-async' import { I18nextProvider } from 'react-i18next' import '@/index.css' -import App from '@/App' +import App, { resolvePageRoute } from '@/App' import { createUII18n } from '@/lib/i18n' -import { prepareHelpPage } from '@/lib/help' import { currentViewingLocale } from '@/lib/locales' import { localesKeys } from '@/lib/queryKeys' import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings' @@ -66,7 +65,13 @@ const start = () => { 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)) + const url = new URL (window.location.href) + const match = resolvePageRoute (url.pathname) + const prepare = match?.route.handle?.ssr + if (match == null || prepare == null) + throw new Error ('Missing SSR route preparation') + const status = await prepare ({ client, params: match.params, + locale: currentViewingLocale (), searchParams: url.searchParams }) if (status !== data.status) throw new Error ('Server and browser Help versions differ') mount (true) diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx index 5370cae..69b07d0 100644 --- a/frontend/src/pages/tags/TagHistoryPage.tsx +++ b/frontend/src/pages/tags/TagHistoryPage.tsx @@ -72,11 +72,10 @@ const renderPrimaryName = ( language: string, label: (key: string) => ReactNode, ) => ( - <> - {snapshot.name} - {snapshot.name != null && snapshot.autoGenerated === true - && label ('nameChanges.uncertified')} - ) + + {snapshot.name} + {snapshot.name != null && snapshot.autoGenerated && label ('nameChanges.uncertified')} + ) const renderAliasDiff = (