このコミットが含まれているのは:
@@ -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)
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする