このコミットが含まれているのは:
2026-10-18 18:54:59 +09:00
コミット 912b941f90
22個のファイルの変更、391行の追加、133行の削除
+5 -1
ファイルの表示
@@ -2,6 +2,7 @@ import axios from 'axios'
import toCamel from 'camelcase-keys'
import { API_BASE_URL } from '@/config'
import { currentRenderContext } from '@/lib/renderContext'
import type { AxiosError, AxiosRequestConfig } from 'axios'
@@ -21,8 +22,11 @@ const withUserCode = (opt?: Opt): AxiosRequestConfig => {
const { locale, ...config } = opt ?? { }
return {
...config,
...(currentRenderContext ()?.apiBaseURL != null
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
...(locale != null && { params: { locale, ...config.params } }),
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '',
headers: { 'X-Transfer-Code': typeof window === 'undefined'
? '' : localStorage.getItem ('user_code') ?? '',
...(config.headers ?? { }) } }
}
+43 -1
ファイルの表示
@@ -1,3 +1,7 @@
import { isLocaleCode, localeLanguage } from '@/lib/locales'
import { helpKeys } from '@/lib/queryKeys'
import type { QueryClient } from '@tanstack/react-query'
import type { ComponentType } from 'react'
type ArticleLanguage = { currentEdition: string; publishedEditions: string[] }
@@ -74,7 +78,7 @@ export const compareHelpEditions = (a: string, b: string): number => {
export const helpEditionDate = (edition: string): Date | null => {
const parsed = parseHelpEdition (edition)
return parsed == null ? null : new Date (`${ parsed.date }T00:00:00`)
return parsed == null ? null : new Date (`${ parsed.date }T00:00:00+09:00`)
}
@@ -93,6 +97,44 @@ export const resolveHelpLanguage = (
: null
export const matchHelpDetail = (pathname: string): RegExpMatchArray | null => {
const matched = pathname.match (/^\/([^/]+)\/help\/([^/]+)\/?$/)
return matched?.[2] === 'changes' ? null : matched
}
// MDX functions stay in memory; both entries load them before rendering.
export const prepareHelpPage = async (client: QueryClient, url: URL): Promise<number> => {
const matched = matchHelpDetail (url.pathname)
if (matched == null || !(isLocaleCode (matched[1])))
return 404
let identifier: string
try
{
identifier = decodeURIComponent (matched[2])
}
catch
{
return 404
}
const article = await client.fetchQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier), staleTime: Infinity })
const language = resolveHelpLanguage (article, localeLanguage (matched[1]))
if (language == null)
return 404
const meta = article!.languages[language]
const edition = url.searchParams.get ('edition') ?? meta.currentEdition
if (!(meta.publishedEditions.includes (edition)) || parseHelpEdition (edition) == null)
return 404
const content = await client.fetchQuery ({
queryKey: helpKeys.edition (identifier, language, edition),
queryFn: () => getHelpEditionContent (identifier, language, edition),
staleTime: Infinity })
return content == null ? 404 : 200
}
export const getHelpEditions = async (
identifier: string,
language: string,
+31 -25
ファイルの表示
@@ -1,13 +1,10 @@
import { createInstance } from 'i18next'
import { initReactI18next } from 'react-i18next'
import type { TOptions } from 'i18next'
import type { i18n as I18n, TOptions } from 'i18next'
import type { LocaleCode } from '@/lib/locales'
export const i18n = createInstance ()
// Keep Japanese placeholders editable without reporting them as completed translations.
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
const untranslated = new Set (
@@ -42,23 +39,31 @@ for (const [path, dictionary] of Object.entries (dictionaries))
const namespaces = Object.keys (resources.ja ?? { })
void i18n.use (initReactI18next).init ({
lng: 'ja',
fallbackLng: 'ja',
supportedLngs: ['ja', 'ain-Kana'],
load: 'currentOnly',
initAsync: false,
defaultNS: 'navigation',
ns: namespaces,
resources,
interpolation: { escapeValue: false },
returnNull: false,
returnEmptyString: false,
saveMissing: import.meta.env.DEV,
missingKeyHandler: (languages, namespace, key) => {
console.warn ('[i18n] missing key', { languages, namespace, key })
},
react: { useSuspense: false } })
export const createUII18n = (locale: string): I18n => {
const instance = createInstance ()
void instance.init ({
lng: locale,
fallbackLng: 'ja',
supportedLngs: ['ja', 'ain-Kana'],
load: 'currentOnly',
initAsync: false,
defaultNS: 'navigation',
ns: namespaces,
resources,
interpolation: { escapeValue: false },
returnNull: false,
returnEmptyString: false,
saveMissing: import.meta.env.DEV,
missingKeyHandler: (languages, namespace, key) => {
console.warn ('[i18n] missing key', { languages, namespace, key })
},
react: { useSuspense: false } })
return instance
}
// This instance is used only by legacy helpers with an explicit lng option.
export const i18n = createUII18n ('ja')
initReactI18next.init (i18n)
export const translateUIMessage = (
@@ -66,19 +71,20 @@ export const translateUIMessage = (
namespace: string,
locale: string,
values: Record<string, string | number> = { },
instance: I18n = i18n,
): { text: string; language: string } => {
const languages = new Set<string> ()
const visiting = new Set<string> ()
const collectLanguages = (reference: string, options: TOptions): string => {
const details = i18n.t (reference, { ...options,
const details = instance.t (reference, { ...options,
returnDetails: true, returnObjects: false, nest: false, skipInterpolation: true })
const identity = `${details.usedLng}:${details.usedNS}:${details.exactUsedKey}`
if (visiting.has (identity) || typeof details.res !== 'string')
return details.usedLng
visiting.add (identity)
const nestingOptions = { ...i18n.options.interpolation, ...options,
const nestingOptions = { ...instance.options.interpolation, ...options,
ns: details.usedNS, lng: details.usedLng }
const remainder = i18n.services.interpolator.nest (details.res,
const remainder = instance.services.interpolator.nest (details.res,
(nestedKey: string, nestedOptions: TOptions) => {
collectLanguages (nestedKey, nestedOptions)
return ' '
@@ -94,7 +100,7 @@ export const translateUIMessage = (
// A single lang attribute cannot describe mixed fallback text: use the full Japanese message.
const mixed = languages.size > 1
const language = mixed ? 'ja' : languages.values ().next ().value ?? resolvedLanguage
const text = i18n.t (key, { ...options, lng: mixed ? 'ja' : resolvedLanguage,
const text = instance.t (key, { ...options, lng: mixed ? 'ja' : resolvedLanguage,
returnDetails: false, returnObjects: false })
if (typeof text !== 'string')
{
+2 -1
ファイルの表示
@@ -1,4 +1,5 @@
import { apiGet } from '@/lib/api'
import { currentRenderContext } from '@/lib/renderContext'
import type { To } from 'react-router-dom'
@@ -43,7 +44,7 @@ export const resolveViewingLocale = (
export const currentViewingLocale = (): LocaleCode =>
resolveViewingLocale (typeof window === 'undefined' ? '' : window.location.pathname)
resolveViewingLocale (currentRenderContext ()?.pathname ?? '')
export const unlocalisedPath = (pathname: string): string => {
+12
ファイルの表示
@@ -0,0 +1,12 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import type { RenderContext } from '@/lib/renderContext'
const requests = new AsyncLocalStorage<RenderContext> ()
export const currentRenderContext = (): RenderContext | undefined => requests.getStore ()
export const withRenderContext = <T,> (context: RenderContext, render: () => T): T =>
requests.run (context, render)
+13
ファイルの表示
@@ -0,0 +1,13 @@
export type RenderContext = {
pathname: string
origin: string
apiBaseURL?: string }
// The server build replaces this module with the AsyncLocalStorage implementation.
export const currentRenderContext = (): RenderContext | undefined => {
if (typeof window === 'undefined')
return undefined
return { pathname: window.location.pathname,
origin: window.location.origin }
}
+2 -1
ファイルの表示
@@ -347,7 +347,8 @@ const safeParseClientSettings = (raw: string | null): ClientSettings => {
export const loadClientSettings = (): ClientSettings =>
safeParseClientSettings (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY))
safeParseClientSettings (typeof window === 'undefined'
? null : localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY))
export const saveClientSettings = (settings: ClientSettings): void => {
+2 -1
ファイルの表示
@@ -10,9 +10,10 @@ import type { ClientBehaviorSettings } from '@/lib/settings'
export const useClientBehaviourSettings = (): ClientBehaviorSettings => {
const [settings, setSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useState<ClientBehaviorSettings> ({ })
useEffect (() => {
setSettings (getClientBehaviorSettings ())
const handleClientSettingsChange = () => {
setSettings (getClientBehaviorSettings ())
}
+3 -3
ファイルの表示
@@ -8,11 +8,11 @@ import { useLocale } from '@/lib/useLocale'
export const useUITranslation = (namespace = 'common') => {
const locale = useLocale ()
useTranslation (namespace, { lng: locale })
const { i18n } = useTranslation (namespace, { lng: locale })
const message = useCallback ((key: string, values?: Record<string, string | number>) => {
return translateUIMessage (key, namespace, locale, values)
}, [namespace, locale])
return translateUIMessage (key, namespace, locale, values, i18n)
}, [namespace, locale, i18n])
const label = useCallback ((key: string, values?: Record<string, string | number>) => {
const result = message (key, values)
+2
ファイルの表示
@@ -34,6 +34,7 @@ const formatDateString = (
{
dateFormatters.set (unknown, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', {
era: 'long', year: 'numeric',
timeZone: 'Asia/Tokyo',
month: unknown === 'month' ? undefined : 'long',
day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric',
weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short',
@@ -45,6 +46,7 @@ const formatDateString = (
}
ainuDateFormatter ??= new Intl.DateTimeFormat ('en-CA', {
year: 'numeric', month: '2-digit', day: '2-digit',
timeZone: 'Asia/Tokyo',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23' })
const parts = ainuDateFormatter.formatToParts (date)