アイヌ語追加 (#428) (#433)

Reviewed-on: #433
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
2026-10-20 20:44:14 +09:00
committed by みてるぞ
コミット 084a090cab
294個のファイルの変更、13978行の追加、3461行の削除
+73 -4
ファイルの表示
@@ -28,11 +28,17 @@ vi.mock ('@/config', () => ({
describe ('api helpers', () => {
beforeEach (() => {
vi.resetModules ()
vi.clearAllMocks ()
localStorage.clear ()
mocks.client.get.mockReset ().mockResolvedValue ({ data: { token: 'csrf-token' } })
mocks.client.post.mockReset ().mockResolvedValue ({ data: { ok: true } })
mocks.client.patch.mockReset ().mockResolvedValue ({ data: { ok: true } })
mocks.client.delete.mockReset ().mockResolvedValue ({ data: '' })
mocks.isAxiosError.mockReturnValue (false)
})
it ('adds the transfer code header and camelizes get responses', async () => {
it ('uses Cookies without sending the recovery code and camelises responses', async () => {
localStorage.setItem ('user_code', 'abc123')
mocks.client.get.mockResolvedValueOnce ({
data: { post_id: 1, nested_value: { created_at: 'now' } },
@@ -47,11 +53,13 @@ describe ('api helpers', () => {
expect (mocks.client.get).toHaveBeenCalledWith (
'/posts/1',
{
headers: { 'X-Transfer-Code': 'abc123', 'X-Extra': '1' },
headers: { 'X-Extra': '1' },
params: { page: 2 },
},
)
expect (data).toEqual ({ postId: 1, nestedValue: { createdAt: 'now' } })
const { default: axios } = await import ('axios')
expect (axios.create).toHaveBeenCalledWith ({ baseURL: '/api', withCredentials: true })
})
it ('passes an empty body for post-like requests when body is omitted', async () => {
@@ -63,7 +71,7 @@ describe ('api helpers', () => {
expect (mocks.client.patch).toHaveBeenCalledWith (
'/posts/1',
{},
{ headers: { 'X-Transfer-Code': '' } },
{ headers: { 'X-CSRF-Token': 'csrf-token' } },
)
expect (data.okValue).toBe (true)
})
@@ -88,9 +96,70 @@ describe ('api helpers', () => {
expect (mocks.client.delete).toHaveBeenCalledWith (
'/posts/1',
{ headers: { 'X-Transfer-Code': '' } },
{ headers: { 'X-CSRF-Token': 'csrf-token' } },
)
expect (isApiError (err)).toBe (true)
expect (mocks.isAxiosError).toHaveBeenCalledWith (err)
})
it ('shares initial CSRF acquisition across concurrent writes and reuses the token', async () => {
let complete!: (value: { data: { token: string } }) => void
mocks.client.get.mockReturnValueOnce (new Promise (resolve => { complete = resolve }))
const { apiPost, apiPatch } = await import ('@/lib/api')
const first = apiPost ('/posts', { title: 'one' })
const second = apiPatch ('/posts/1', { title: 'two' })
expect (mocks.client.get).toHaveBeenCalledTimes (1)
expect (mocks.client.post).not.toHaveBeenCalled ()
complete ({ data: { token: 'shared-token' } })
await Promise.all ([first, second])
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (1)
expect (mocks.client.post).toHaveBeenCalledWith ('/posts', { title: 'one' }, {
headers: { 'X-CSRF-Token': 'shared-token' } })
expect (mocks.client.patch).toHaveBeenLastCalledWith ('/posts/1', { }, {
headers: { 'X-CSRF-Token': 'shared-token' } })
})
it.each (['/users', '/users/verify', '/users/code/renew']) (
'refreshes CSRF after a session change through %s', async path => {
mocks.client.get.mockResolvedValueOnce ({ data: { token: 'before' } })
mocks.client.get.mockResolvedValueOnce ({ data: { token: 'after' } })
mocks.client.post.mockResolvedValueOnce ({ data: { valid: true } })
const { apiPost, apiPatch } = await import ('@/lib/api')
await apiPost (path)
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
expect (mocks.client.patch).toHaveBeenCalledWith ('/posts/1', { }, {
headers: { 'X-CSRF-Token': 'after' } })
})
it ('clears the recovery code and CSRF token after session deletion', async () => {
localStorage.setItem ('user_code', 'backup')
const { apiDelete, apiPatch } = await import ('@/lib/api')
await apiDelete ('/users/session')
expect (localStorage.getItem ('user_code')).toBeNull ()
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
it ('clears rejected CSRF tokens without replaying a write', async () => {
const error = { response: { status: 403, headers: { 'x-csrf-invalid': '1' } } }
mocks.isAxiosError.mockReturnValue (true)
mocks.client.patch.mockRejectedValueOnce (error)
const { apiPatch } = await import ('@/lib/api')
await expect (apiPatch ('/posts/1')).rejects.toBe (error)
expect (mocks.client.patch).toHaveBeenCalledTimes (1)
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
it ('allows CSRF acquisition to recover after a failed request', async () => {
const error = new Error ('offline')
mocks.client.get.mockRejectedValueOnce (error)
const { apiPatch } = await import ('@/lib/api')
await expect (apiPatch ('/posts/1')).rejects.toBe (error)
expect (mocks.client.patch).not.toHaveBeenCalled ()
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
})
+136 -9
ファイルの表示
@@ -2,22 +2,130 @@ 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'
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { LocaleCode } from '@/lib/locales'
import type { RenderContext } from '@/lib/renderContext'
type Opt = {
locale?: LocaleCode
params?: AxiosRequestConfig['params']
headers?: Record<string, string>
signal?: AbortSignal
responseType?: 'blob' }
const client = axios.create ({ baseURL: API_BASE_URL })
const client = axios.create ({ baseURL: API_BASE_URL, withCredentials: true })
type CSRFState = NonNullable<RenderContext['csrf']>
let browserCSRF: CSRFState = { }
const withUserCode = (opt?: Opt): Opt => ({
...opt,
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '',
...(opt?.headers ?? { }) } })
const csrfState = (): CSRFState => {
if (typeof window !== 'undefined')
return browserCSRF
const context = currentRenderContext ()
if (context == null)
throw new Error ('Missing request context for CSRF')
return context.csrf ??= { }
}
const resetCSRF = (): void => {
if (typeof window !== 'undefined')
browserCSRF = { }
else
{
const context = currentRenderContext ()
if (context != null)
context.csrf = { }
}
}
const withSession = (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: { ...(currentRenderContext ()?.cookie != null && {
Cookie: currentRenderContext ()!.cookie }),
...(typeof window === 'undefined' && currentRenderContext () != null && {
'X-Forwarded-Host': new URL (currentRenderContext ()!.origin).host,
'X-Forwarded-Proto': new URL (currentRenderContext ()!.origin)
.protocol.replace (':', '') }),
...(currentRenderContext ()?.forwardedFor != null && {
'X-Forwarded-For': currentRenderContext ()!.forwardedFor }),
...(config.headers ?? { }) } }
}
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
const token = await csrfToken ()
const config = withSession (opt)
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
}
const csrfToken = async (): Promise<string> => {
const state = csrfState ()
if (state.token != null)
return state.token
state.pending ??= client.get<{ token: string }> ('/users/csrf', withSession ())
.then (response => {
if (csrfState () !== state)
return csrfToken ()
if (typeof response.data.token !== 'string')
throw new Error ('Missing CSRF token')
rememberSessionCookies (response.headers)
state.token = response.data.token
delete state.pending
return state.token
}).catch (error => {
delete state.pending
throw error
})
return state.pending
}
const csrfWrite = async <T,> (
send: (config: AxiosRequestConfig) => Promise<AxiosResponse<T>>,
opt?: Opt,
): Promise<AxiosResponse<T>> => {
try
{
return await send (await withCSRF (opt))
}
catch (error)
{
if (axios.isAxiosError (error)
&& error.response?.headers?.['x-csrf-invalid'] === '1')
resetCSRF ()
throw error
}
}
const rememberSessionCookies = (headers: AxiosResponse['headers']) => {
const context = currentRenderContext ()
const cookies = headers?.['set-cookie']
if (typeof window === 'undefined' && context != null && Array.isArray (cookies))
{
context.setCookies = cookies
const pairs = new Map ((context.cookie ?? '').split (';').filter (Boolean)
.map (value => value.trim ().split (/=(.*)/s).slice (0, 2) as [string, string]))
for (const cookie of cookies)
{
const pair = cookie.split (';')[0]
const separator = pair.indexOf ('=')
pairs.set (pair.slice (0, separator), pair.slice (separator + 1))
}
context.cookie = [...pairs].map (([key, value]) => `${key}=${value}`).join ('; ')
}
}
const apiP = async <T> (
@@ -25,7 +133,11 @@ const apiP = async <T> (
path: string,
body?: unknown,
opt?: Opt): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt))
const res = await csrfWrite (config => client[method] (path, body ?? { }, config), opt)
if (method === 'post' && (path === '/users' || path === '/users/code/renew'
|| (path === '/users/verify' && res.data?.valid)))
resetCSRF ()
rememberSessionCookies (res.headers)
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
@@ -35,7 +147,8 @@ const apiP = async <T> (
export const apiGet = async <T> (
path: string,
opt?: Opt): Promise<T> => {
const res = await client.get (path, withUserCode (opt))
const res = await client.get (path, withSession (opt))
rememberSessionCookies (res.headers)
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
@@ -63,7 +176,21 @@ export const apiPatch = async <T> (
export const apiDelete = async <T = void> (
path: string,
opt?: Opt): Promise<T> => {
const res = await client.delete (path, withUserCode (opt))
const res = await csrfWrite (config => client.delete (path, config), opt)
if (path === '/users/session')
resetCSRF ()
rememberSessionCookies (res.headers)
if (typeof window !== 'undefined' && path === '/users/session')
{
try
{
localStorage.removeItem ('user_code')
}
catch
{
// The server session is already cleared even if storage is unavailable.
}
}
if (res.data == null || res.data === '')
return undefined as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
+11 -9
ファイルの表示
@@ -4,10 +4,11 @@ import type { ReactNode } from 'react'
type DialogueVariant = 'default' | 'danger'
type ConfirmOptions = { title: string
type ConfirmOptions = { title: ReactNode
description?: ReactNode
confirmText?: string
cancelText?: string
confirmText?: ReactNode
cancelText?: ReactNode
closeLabel?: ReactNode
variant?: DialogueVariant }
type AlertOptions = { title: string
@@ -15,16 +16,17 @@ type AlertOptions = { title: string
okText?: string }
type Choice<T extends string> = { value: T
label: string
label: ReactNode
variant?: DialogueVariant }
type ChoiceOptions<T extends string> = { title: string
type ChoiceOptions<T extends string> = { title: ReactNode
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }
cancelText?: ReactNode
closeLabel?: ReactNode }
type DialogueFormAction = {
label: string
label: ReactNode
placement?: 'start' | 'end'
variant?: DialogueVariant
disabled?: boolean
@@ -35,10 +37,10 @@ type DialogueFormControls = {
setActions: (actions: DialogueFormAction[]) => void
confirm: (options: ConfirmOptions) => Promise<boolean> }
type DialogueFormOptions = { title: string
type DialogueFormOptions = { title: ReactNode
description?: ReactNode
body: (controls: DialogueFormControls) => ReactNode
cancelText?: string
cancelText?: ReactNode
size?: 'default' | 'large' }
type DialogueAPI =
+5 -5
ファイルの表示
@@ -52,7 +52,7 @@ describe('Gekanator API functions', () => {
mockedApiGet.mockResolvedValueOnce({ posts })
await expect(fetchGekanatorPosts()).resolves.toEqual(posts)
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts')
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts', { locale: 'ja' })
})
it('returns questions from the Gekanator questions endpoint', async () => {
@@ -60,7 +60,7 @@ describe('Gekanator API functions', () => {
mockedApiGet.mockResolvedValueOnce({ questions })
await expect(fetchGekanatorQuestions()).resolves.toEqual(questions)
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions')
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions', { locale: 'ja' })
})
})
@@ -458,7 +458,7 @@ describe('Gekanator API writers', () => {
original_answer: 'partial',
},
],
})
}, { locale: 'ja' })
})
it('sends question suggestions using snake_case request keys', async () => {
@@ -482,7 +482,7 @@ describe('Gekanator API writers', () => {
gekanator_game_id: 100,
question_text: '喜多ちゃんが泣いてる?',
answer: 'yes',
})
}, { locale: 'ja' })
})
it('sends extra question answers using snake_case request keys', async () => {
@@ -517,7 +517,7 @@ describe('Gekanator API writers', () => {
answer: 'probably_no',
},
],
},
}, { locale: 'ja' },
)
})
})
+31 -15
ファイルの表示
@@ -1,5 +1,6 @@
import { apiGet, apiPost } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { Post } from '@/types'
export type GekanatorAnswerValue =
@@ -411,24 +412,30 @@ export const storeGekanatorQuestion = (
exampleAnswers: question.exampleAnswers })
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
export const fetchGekanatorPosts = async (
locale: LocaleCode = 'ja',
): Promise<Post[]> => {
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts', { locale })
return data.posts
}
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
export const fetchGekanatorQuestions = async (
locale: LocaleCode = 'ja',
): Promise<StoredGekanatorQuestion[]> => {
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions', { locale })
return data.questions
}
export const fetchGekanatorExtraQuestions = async (
gameId: number,
nonce?: string): Promise<GekanatorExtraQuestion[]> => {
nonce?: string,
locale: LocaleCode = 'ja',
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined })
{ locale, params: nonce ? { nonce } : undefined })
return data.questions
}
@@ -606,7 +613,8 @@ export const buildGekanatorQuestions = (
}
export const saveGekanatorGame = async ({
export const saveGekanatorGame = async (
{
guessedPostId,
correctPostId,
answers,
@@ -614,7 +622,9 @@ export const saveGekanatorGame = async ({
guessedPostId: number
correctPostId: number
answers: GekanatorAnswerLog[]
}): Promise<{ id: number; learnedExampleCount: number }> =>
},
locale: LocaleCode = 'ja',
): Promise<{ id: number; learnedExampleCount: number }> =>
await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId,
correct_post_id: correctPostId,
@@ -627,10 +637,11 @@ export const saveGekanatorGame = async ({
effective_question: answer.effectiveQuestion,
learning_question: answer.learningQuestion,
answer: answer.answer,
original_answer: answer.originalAnswer })) })
original_answer: answer.originalAnswer })) }, { locale })
export const saveGekanatorQuestionSuggestion = async ({
export const saveGekanatorQuestionSuggestion = async (
{
gekanatorGameId,
existingQuestionId,
questionText,
@@ -640,22 +651,27 @@ export const saveGekanatorQuestionSuggestion = async ({
existingQuestionId?: number
questionText?: string
answer: GekanatorAnswerValue
}): Promise<{ id: number; count: number }> =>
},
locale: LocaleCode = 'ja',
): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId,
existing_question_id: existingQuestionId,
question_text: questionText,
answer })
answer }, { locale })
export const saveGekanatorExtraQuestionAnswers = async ({
export const saveGekanatorExtraQuestionAnswers = async (
{
gameId,
answers,
}: {
gameId: number
answers: { questionId: number; answer: GekanatorAnswerValue }[]
}) =>
},
locale: LocaleCode = 'ja',
) =>
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
answers: answers.map (item => ({
question_id: item.questionId,
answer: item.answer })) })
answer: item.answer })) }, { locale })
+12 -3
ファイルの表示
@@ -29,7 +29,7 @@ describe ('Help file catalogue', () => {
const { api } = createHelpFixture ()
expect (api.parseHelpEdition (edition)).toEqual ({ date: '2026-10-02', revision })
const date = api.helpEditionDate (edition)!
expect ([date.getFullYear (), date.getMonth (), date.getDate ()]).toEqual ([2026, 9, 2])
expect (date.toISOString ()).toBe ('2026-10-01T15:00:00.000Z')
})
it.each (['missing', '2026-02-30', '2026-10-02.0', '2026-10-02.2.extra',
@@ -51,9 +51,9 @@ describe ('Help file catalogue', () => {
expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition)
.toBe ('2026-10-02.10')
expect (await api.getHelpSearchEntries ('ja')).toEqual ([
{ identifier: 'guide', edition: '2026-10-02.10',
{ identifier: 'guide', language: 'ja', edition: '2026-10-02.10',
title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' },
{ identifier: '設定 手順', edition: '2026-10-03',
{ identifier: '設定 手順', language: 'ja', edition: '2026-10-03',
title: '設定', body: '\nタグを設定する' }])
})
@@ -66,6 +66,15 @@ describe ('Help file catalogue', () => {
.not.toContain ('2026-10-02.11')
})
it ('does not substitute Japanese articles, search or changes for missing Ainu content',
async () => {
const { api } = createHelpFixture ()
const article = await api.getHelpArticle ('guide')
expect (api.resolveHelpLanguage (article, 'ain')).toBeNull ()
expect (await api.getHelpSearchEntries ('ain')).toEqual ([])
expect (await api.getHelpChanges ('ain')).toEqual ([])
})
it ('uses generated edition titles without reparsing raw sources', async () => {
const { api, raw } = createHelpFixture ()
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
+44 -3
ファイルの表示
@@ -1,5 +1,10 @@
import { localeLanguage } from '@/lib/locales'
import { helpKeys } from '@/lib/queryKeys'
import type { ComponentType } from 'react'
import type { PagePreparation } from '@/lib/routeLocales'
type ArticleLanguage = { currentEdition: string; publishedEditions: string[] }
export type ArticleMeta = {
@@ -13,12 +18,14 @@ export type HelpEditionText = {
export type HelpSearchEntry = HelpEditionText & {
identifier: string
language?: string
edition: string }
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
export type HelpEditionSource = HelpEditionText & { source: string }
export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & {
language?: string
pred: string | null
change: 'new' | 'update'
date: string }
@@ -72,7 +79,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`)
}
@@ -82,6 +89,40 @@ export const getHelpArticle = async (identifier: string): Promise<ArticleMeta |
}
export const resolveHelpLanguage = (
article: ArticleMeta | null | undefined,
requestedLanguage: string,
): string | null =>
article?.languages[requestedLanguage] != null
? requestedLanguage
: null
// MDX functions stay in memory; both entries load them before rendering.
export const prepareHelpPage = async (
{ client, params, locale, searchParams }: PagePreparation,
): Promise<number> => {
const identifier = params.identifier
if (identifier == null)
return 404
const article = await client.fetchQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier), staleTime: Infinity })
const language = resolveHelpLanguage (article, localeLanguage (locale))
if (language == null)
return 404
const meta = article!.languages[language]
const edition = 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,
@@ -144,7 +185,7 @@ export const getHelpSearchEntries = async (
language: string,
): Promise<HelpSearchEntry[]> => {
const { default: search } = await import ('virtual:help/search')
return search[language] ?? []
return (search[language] ?? []).map (entry => ({ ...entry, language }))
}
@@ -163,7 +204,7 @@ export const getHelpEditionContent = async (
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
const { default: changes } = await import ('virtual:help/changes')
return changes[language] ?? []
return (changes[language] ?? []).map (entry => ({ ...entry, language }))
}
+3 -3
ファイルの表示
@@ -43,7 +43,7 @@ describe ('Help prefetch integration', () => {
<PrefetchLink to="/help/guide">Help</PrefetchLink>
<Routes>
<Route path="/" element={null}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/ja/help/:identifier" element={<HelpDetailPage/>}/>
</Routes>
</>)
const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx']
@@ -84,14 +84,14 @@ describe ('Help prefetch integration', () => {
<PrefetchLink to={path}>diff</PrefetchLink>
<Routes>
<Route path="/" element={null}/>
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
<Route path="/ja/help/:identifier/diff" element={<HelpDiffPage/>}/>
</Routes>
</>)
await Promise.all ([prefetchers.prefetchForURL (queryClient, path),
prefetchers.prefetchForURL (queryClient, path)])
fireEvent.click (screen.getByRole ('link', { name: 'diff' }))
expect (await screen.findByRole ('link', { name: '旧ホーム (2026-10-01)' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-01')
.toHaveAttribute ('href', '/ja/help/guide?edition=2026-10-01')
expect (screen.getByText ('+ # ホーム第十版')).toBeInTheDocument ()
Object.entries (helpFixture.raw).forEach (([name, load]) => {
const expected = /\/ja\/(2026-10-01|2026-10-02\.10)\.mdx$/.test (name) ? 1 : 0
+141
ファイルの表示
@@ -0,0 +1,141 @@
import { createInstance } from 'i18next'
import { initReactI18next } from 'react-i18next'
import type { i18n as I18n, TOptions } from 'i18next'
import type { LocaleCode } from '@/lib/locales'
// Keep Japanese placeholders editable without reporting them as completed translations.
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
const untranslated = new Set (
Array.isArray (resource._untranslated) ? resource._untranslated : [])
if (untranslated.has ('*'))
return { }
const select = (entries: Record<string, unknown>, prefix = ''): Record<string, unknown> =>
Object.fromEntries (Object.entries (entries).flatMap (([key, value]) => {
const path = prefix ? `${ prefix }.${ key }` : key
if (key === '_untranslated' || untranslated.has (path))
return []
const translated =
(value != null && typeof value === 'object' && !(Array.isArray (value)))
? select (value as Record<string, unknown>, path)
: value
return [[key, translated]]
}))
return select (resource)
}
const dictionaries = import.meta.glob<Record<string, unknown>> ('../locales/*/*.json', {
eager: true, import: 'default' })
const resources: Record<string, Record<string, Record<string, unknown>>> = { }
for (const [path, dictionary] of Object.entries (dictionaries))
{
const [, , locale, filename] = path.split ('/')
resources[locale] ??= { }
resources[locale][filename.slice (0, -5)] =
locale === 'ain-Kana' ? translatedResource (dictionary) : dictionary
}
const namespaces = Object.keys (resources.ja ?? { })
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 = (
key: string,
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 = 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 = { ...instance.options.interpolation, ...options,
ns: details.usedNS, lng: details.usedLng }
const remainder = instance.services.interpolator.nest (details.res,
(nestedKey: string, nestedOptions: TOptions) => {
collectLanguages (nestedKey, nestedOptions)
return ' '
}, nestingOptions)
// References, punctuation and supplied values do not define the wrapper's language.
if (/\p{L}/u.test (remainder.replace (/\{\{[^}]*\}\}/g, '')))
languages.add (details.usedLng)
visiting.delete (identity)
return details.usedLng
}
const options = { ...values, ns: namespace, lng: locale }
const resolvedLanguage = collectLanguages (key, options)
// 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 = instance.t (key, { ...options, lng: mixed ? 'ja' : resolvedLanguage,
returnDetails: false, returnObjects: false })
if (typeof text !== 'string')
{
console.warn ('[i18n] UI message must be a string', { key, namespace, locale })
return { text: key, language }
}
return { text, language }
}
export const untranslatedKeys = (
locale: LocaleCode,
namespace = 'navigation',
): string[] => {
const collect = (resource: Record<string, unknown>, prefix = ''): string[] =>
Object.entries (resource).flatMap (([key, value]) => {
const path = prefix ? `${ prefix }.${ key }` : key
return (value != null && typeof value === 'object')
? collect (value as Record<string, unknown>, path)
: [path]
})
const baseline = i18n.getResourceBundle ('ja', namespace) ?? { }
return collect (baseline).filter (key => {
const value = i18n.getResource (locale, namespace, key)
return typeof value !== 'string' || value.length === 0
})
}
if (import.meta.env.DEV)
{
for (const namespace of namespaces)
{
const missing = untranslatedKeys ('ain-Kana', namespace)
if (missing.length > 0)
console.warn (`[i18n] ain-Kana: untranslated ${ namespace } keys`, missing)
}
}
+49 -21
ファイルの表示
@@ -1,3 +1,6 @@
import { unlocalisedPath } from '@/lib/locales'
import { i18n } from '@/lib/i18n'
export type ShortcutScope =
| 'global'
| 'posts'
@@ -37,85 +40,109 @@ export type ShortcutDefinition = {
}
export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = {
global: '全画面',
posts: '広場',
postSearch: '広場検索',
tags: 'タグ',
settings: '設定',
wiki: 'Wiki',
materials: '素材',
get global () { return i18n.t ('settings:shortcuts.scopes.global') },
get posts () { return i18n.t ('settings:shortcuts.scopes.posts') },
get postSearch () { return i18n.t ('settings:shortcuts.scopes.postSearch') },
get tags () { return i18n.t ('settings:shortcuts.scopes.tags') },
get settings () { return i18n.t ('settings:shortcuts.scopes.settings') },
get wiki () { return i18n.t ('settings:shortcuts.scopes.wiki') },
get materials () { return i18n.t ('settings:shortcuts.scopes.materials') },
}
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
{
id: 'global.openShortcutHelp',
label: 'ショートカット一覧を開く',
get label () {
return i18n.t ('settings:shortcuts.actions.global.openShortcutHelp')
},
scope: 'global',
defaultBinding: { key: '?', shift: true },
},
{
id: 'global.focusSearch',
label: '検索欄へ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.global.focusSearch')
},
scope: 'global',
defaultBinding: { key: '/' },
},
{
id: 'navigation.posts',
label: '広場へ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.navigation.posts')
},
scope: 'global',
defaultBinding: { key: 'p' },
},
{
id: 'navigation.tags',
label: 'タグへ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.navigation.tags')
},
scope: 'global',
defaultBinding: { key: 't' },
},
{
id: 'navigation.materials',
label: '素材へ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.navigation.materials')
},
scope: 'global',
defaultBinding: { key: 'm' },
},
{
id: 'navigation.wiki',
label: 'Wiki へ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.navigation.wiki')
},
scope: 'global',
defaultBinding: { key: 'w' },
},
{
id: 'pagination.next',
label: '次のページへ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.pagination.next')
},
scope: 'global',
defaultBinding: { key: 'ArrowRight' },
},
{
id: 'pagination.previous',
label: '前のページへ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.pagination.previous')
},
scope: 'global',
defaultBinding: { key: 'ArrowLeft' },
},
{
id: 'settings.account',
label: '設定: アカウント',
get label () {
return i18n.t ('settings:shortcuts.actions.settings.account')
},
scope: 'settings',
defaultBinding: { key: '1' },
},
{
id: 'settings.behavior',
label: '設定: 動作',
get label () {
return i18n.t ('settings:shortcuts.actions.settings.behavior')
},
scope: 'settings',
defaultBinding: { key: '2' },
},
{
id: 'settings.theme',
label: '設定: テーマ',
get label () {
return i18n.t ('settings:shortcuts.actions.settings.theme')
},
scope: 'settings',
defaultBinding: { key: '3' },
},
{
id: 'settings.keyboard',
label: '設定: キーボード',
get label () {
return i18n.t ('settings:shortcuts.actions.settings.keyboard')
},
scope: 'settings',
defaultBinding: { key: '4' },
},
@@ -249,7 +276,7 @@ const keyLabel = (key: string): string => {
export const formatKeyBinding = (binding: KeyBinding | null): string => {
if (binding == null)
return '未割当'
return i18n.t ('settings:shortcuts.unassigned')
return [
binding.ctrl ? 'Ctrl' : null,
@@ -308,7 +335,8 @@ export const getConflictingShortcutActionIds = (
)
}
export const resolveShortcutScope = (pathname: string): ShortcutScope => {
export const resolveShortcutScope = (fullPathname: string): ShortcutScope => {
const pathname = unlocalisedPath (fullPathname)
if (pathname.startsWith ('/posts/search'))
return 'postSearch'
if (pathname.startsWith ('/posts'))
+55
ファイルの表示
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { isLocaleCode,
legacyLocalePath,
localeFromPath,
localiseTo,
switchLocalePath,
unlocalisedPath } from '@/lib/locales'
describe ('locale URL generation', () => {
it.each (['ja', 'ain-Kana']) ('recognises the supported locale %s', locale => {
expect (isLocaleCode (locale)).toBe (true)
expect (localeFromPath (`/${ locale }/posts`)).toBe (locale)
})
it.each (['en', 'ain', 'JA', '', undefined]) ('rejects %s', locale => {
expect (isLocaleCode (locale)).toBe (false)
})
it.each ([
'/posts', '/posts/new', '/posts/search', '/posts/42', '/posts/changes',
'/tags', '/tags/42', '/tags/42/deerjikists', '/tags/nico', '/nico/tags', '/tags/changes',
'/materials', '/materials/new', '/materials/42', '/materials/changes',
'/materials/suppressions', '/theatres/42', '/wiki', '/wiki/new', '/wiki/title',
'/wiki/42/edit', '/wiki/42/diff', '/wiki/changes', '/help', '/help/changes',
'/help/article', '/help/article/diff', '/users/settings', '/settings',
'/tos', '/gekanator', '/more']) ('localises and redirects %s', pathname => {
const location = { pathname, search: '?page=2&tag=a%23b', hash: '#section' }
expect (localiseTo (pathname, 'ain-Kana')).toBe (`/ain-Kana${ pathname }`)
expect (legacyLocalePath (location)).toBe (`/ja${ pathname }${ location.search }#section`)
expect (unlocalisedPath (`/ain-Kana${ pathname }`)).toBe (pathname)
})
it ('preserves the resource, query and fragment on locale changes', () => {
expect (switchLocalePath ({ pathname: '/ja/tags/42', search: '?x=1&x=2', hash: '#history' },
'ain-Kana')).toBe ('/ain-Kana/tags/42?x=1&x=2#history')
})
it ('preserves explicit locales and relative, fragment and external targets', () => {
for (const target of ['/ja/posts', '/ain-Kana/posts', '?page=2', '#body',
'../changes', 'https://example.com/posts', '//example.com/posts'])
expect (localiseTo (target, 'ain-Kana')).toBe (target)
})
it ('preserves structured targets', () => {
expect (localiseTo ({ pathname: '/tags/42', search: '?q=one', hash: '#two' }, 'ain-Kana'))
.toEqual ({ pathname: '/ain-Kana/tags/42', search: '?q=one', hash: '#two' })
})
it.each (['/en/posts', '/ain/posts', '/unknown', '/api/tags', '/assets/image.png']) (
'does not redirect invalid or non-page paths: %s', pathname => {
expect (legacyLocalePath ({ pathname, search: '', hash: '' })).toBeNull ()
})
})
+83
ファイルの表示
@@ -0,0 +1,83 @@
import { apiGet } from '@/lib/api'
import { currentRenderContext } from '@/lib/renderContext'
import type { To } from 'react-router-dom'
export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const
export type LocaleCode = typeof SUPPORTED_LOCALES[number]
export type AvailableLocale = { code: string; name: string }
export const fetchLocales = (): Promise<{ locales: AvailableLocale[] }> =>
apiGet ('/locales')
export const LOCALE_LABELS: Record<LocaleCode, string> = {
ja: '日本語', 'ain-Kana': 'ヤウンモシルン アイヌ イタㇰ' }
const LEGACY_ROOTS = [
'posts', 'tags', 'nico', 'theatres', 'materials', 'wiki', 'help',
'users', 'settings', 'tos', 'gekanator', 'more']
export const isLocaleCode = (value: unknown): value is LocaleCode =>
SUPPORTED_LOCALES.some (locale => locale === value)
export const localeLanguage = (locale: LocaleCode): 'ja' | 'ain' =>
locale === 'ja' ? 'ja' : 'ain'
export const localeFromPath = (pathname: string): LocaleCode | null => {
const segment = pathname.split (/[/?#]/)[1]
return isLocaleCode (segment) ? segment : null
}
export const resolveViewingLocale = (
pathname: string,
routeLocale?: unknown,
): LocaleCode =>
isLocaleCode (routeLocale) ? routeLocale : localeFromPath (pathname) ?? 'ja'
export const currentViewingLocale = (): LocaleCode =>
resolveViewingLocale (currentRenderContext ()?.pathname ?? '')
export const unlocalisedPath = (pathname: string): string => {
const locale = localeFromPath (pathname)
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
}
export const localiseTo = (to: To, locale: LocaleCode): To => {
const pathname = typeof to === 'string' ? to : to.pathname
if (pathname == null
|| !(pathname.startsWith ('/'))
|| pathname.startsWith ('//')
|| localeFromPath (pathname) != null)
return to
const localised = `/${ locale }${ pathname }`
return typeof to === 'string' ? localised : { ...to, pathname: localised }
}
export const switchLocalePath = (
location: { pathname: string; search: string; hash: string },
locale: LocaleCode,
): string =>
`/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }`
export const legacyLocalePath = (
location: { pathname: string; search: string; hash: string },
): string | null => {
const root = location.pathname.split ('/')[1]
return (location.pathname === '/' || LEGACY_ROOTS.includes (root))
? `/ja${ location.pathname }${ location.search }${ location.hash }`
: null
}
+38 -17
ファイルの表示
@@ -1,5 +1,6 @@
import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { Material,
MaterialIndexResponse,
MaterialVersion,
@@ -36,8 +37,10 @@ export const fetchMaterials = async (
updatedFrom, updatedTo, sort, direction, page,
tagId, includeDescendants, groupBy,
limit }: FetchMaterialsParams,
locale: LocaleCode = 'ja',
): Promise<MaterialIndexResponse> =>
await apiGet ('/materials', { params: {
await apiGet ('/materials', { locale, params: {
...(q && { q }),
tag_state: tagState,
media_kind: mediaKind,
@@ -54,10 +57,13 @@ export const fetchMaterials = async (
limit} })
export const fetchMaterial = async (id: string): Promise<Material | null> => {
export const fetchMaterial = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Material | null> => {
try
{
return await apiGet (`/materials/${ id }`)
return await apiGet (`/materials/${ id }`, { locale })
}
catch (error)
{
@@ -75,8 +81,10 @@ export const fetchMaterialChanges = async (
eventType?: string
page: number
limit: number
}): Promise<MaterialChangesResponse> =>
await apiGet ('/materials/versions', { params: {
},
locale: LocaleCode = 'ja',
): Promise<MaterialChangesResponse> =>
await apiGet ('/materials/versions', { locale, params: {
...(materialId && { material_id: materialId }),
...(tag && { tag }),
...(eventType && { event_type: eventType }),
@@ -85,19 +93,23 @@ export const fetchMaterialChanges = async (
export const fetchMaterialTagTree = async (
{ parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { params: {
{ parentId, materialFilter }: FetchMaterialTreeParams,
locale: LocaleCode = 'ja',
): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { locale, params: {
...(parentId != null && { parent: String (parentId) }),
material_filter: materialFilter} })
export const fetchMaterialTagByName = async (
name: string,
materialFilter: MaterialFilter): Promise<MaterialTagTree | null> => {
materialFilter: MaterialFilter,
locale: LocaleCode = 'ja',
): Promise<MaterialTagTree | null> => {
try
{
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
{ params: { material_filter: materialFilter } })
{ locale, params: { material_filter: materialFilter } })
}
catch (error)
{
@@ -108,19 +120,26 @@ export const fetchMaterialTagByName = async (
}
export const createMaterial = async (formData: FormData): Promise<Material> =>
await apiPost ('/materials', formData)
export const createMaterial = async (
formData: FormData,
locale: LocaleCode = 'ja',
): Promise<Material> =>
await apiPost ('/materials', formData, { locale })
export const updateMaterial = async (
id: string,
formData: FormData): Promise<Material> =>
await apiPut (`/materials/${ id }`, formData)
formData: FormData,
locale: LocaleCode = 'ja',
): Promise<Material> =>
await apiPut (`/materials/${ id }`, formData, { locale })
export const fetchMaterialSyncSuppressions =
async (): Promise<MaterialSyncSuppressionResponse> =>
await apiGet ('/materials/suppressions')
async (
locale: LocaleCode = 'ja',
): Promise<MaterialSyncSuppressionResponse> =>
await apiGet ('/materials/suppressions', { locale })
export const createMaterialSyncSuppression = async (
@@ -130,10 +149,12 @@ export const createMaterialSyncSuppression = async (
drivePath?: string
driveFileId?: string
reason: string
}): Promise<MaterialSyncSuppression> =>
},
locale: LocaleCode = 'ja',
): Promise<MaterialSyncSuppression> =>
await apiPost ('/materials/suppressions', {
source_kind: payload.sourceKind,
source_uri: payload.sourceUri,
drive_path: payload.drivePath,
drive_file_id: payload.driveFileId,
reason: payload.reason})
reason: payload.reason}, { locale })
+19
ファイルの表示
@@ -0,0 +1,19 @@
import { translateUIMessage } from '@/lib/i18n'
import type navigation from '@/locales/ja/navigation.json'
import type { LocaleCode } from '@/lib/locales'
export type UIMessageKey = keyof typeof navigation
export const uiMessage = (
locale: LocaleCode,
key: UIMessageKey,
values: Record<string, string | number> = { },
): string => translateUIMessage (key, 'navigation', locale, values).text
export const uiMessageLanguage = (locale: LocaleCode, key: UIMessageKey): 'ja' | 'ain' =>
translateUIMessage (key, 'navigation', locale).language === 'ain-Kana'
? 'ain'
: 'ja'
+10 -6
ファイルの表示
@@ -1,3 +1,5 @@
import { translateUIMessage } from '@/lib/i18n'
import type { PostImportSourceIssue } from '@/lib/postImportTypes'
const MAX_ROWS = 100
@@ -67,7 +69,8 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: `取込件数は ${ MAX_ROWS } 件までです.`,
message: translateUIMessage ('validation.tooMany', 'imports', 'ja', {
count: MAX_ROWS }).text,
url: displayUrl })
return
}
@@ -75,7 +78,7 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: 'URL が長すぎます.',
message: translateUIMessage ('validation.urlTooLong', 'imports', 'ja').text,
url: displayUrl })
return
}
@@ -84,7 +87,7 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: 'URL の形式が不正です.',
message: translateUIMessage ('validation.invalidUrl', 'imports', 'ja').text,
url: displayUrl })
return
}
@@ -92,7 +95,7 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: 'HTTP または HTTPS の URL ではありません.',
message: translateUIMessage ('validation.protocol', 'imports', 'ja').text,
url: displayUrl })
return
}
@@ -100,7 +103,7 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: 'URL の形式が不正です.',
message: translateUIMessage ('validation.invalidUrl', 'imports', 'ja').text,
url: displayUrl })
return
}
@@ -110,7 +113,8 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: `${ duplicateRow } 行目と同じ URL です.`,
message: translateUIMessage ('validation.duplicateRow', 'imports', 'ja', {
row: duplicateRow }).text,
url: displayUrl })
return
}
+5 -3
ファイルの表示
@@ -1,3 +1,5 @@
import { translateUIMessage } from '@/lib/i18n'
import type { StorageErrorHandler } from '@/lib/postImportTypes'
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
@@ -16,7 +18,7 @@ const readStorage = (
}
catch
{
onError?.('保存済みデータを読み込めませんでした.')
onError?.(translateUIMessage ('storage.readFailed', 'imports', 'ja').text)
return null
}
}
@@ -37,7 +39,7 @@ const writeStorage = (
}
catch
{
onError?.('ブラウザへ保存できませんでした.')
onError?.(translateUIMessage ('storage.writeFailed', 'imports', 'ja').text)
return false
}
}
@@ -56,7 +58,7 @@ const removeStorage = (
}
catch
{
onError?.('保存済みデータを削除できませんでした.')
onError?.(translateUIMessage ('storage.clearFailed', 'imports', 'ja').text)
}
}
+4 -1
ファイルの表示
@@ -48,6 +48,7 @@ describe ('posts API functions', () => {
expect (api.apiGet).toHaveBeenCalledWith (
'/posts',
{
locale: 'ja',
params: {
title: 'title',
tags: 'a b',
@@ -89,6 +90,7 @@ describe ('posts API functions', () => {
original_created_before: '2026-01-02T00:00:00Z',
},
{
locale: 'ja',
params: {
base_version_no: '7',
force: '1',
@@ -113,7 +115,7 @@ describe ('posts API functions', () => {
expect (api.apiGet).toHaveBeenCalledWith (
'/posts/versions',
{ params: { page: 2, limit: 50 } },
{ locale: 'ja', params: { page: 2, limit: 50 } },
)
})
@@ -129,6 +131,7 @@ describe ('posts API functions', () => {
expect (api.apiGet).toHaveBeenCalledWith (
'/posts/versions',
{
locale: 'ja',
params: {
external_tag: '7',
page: 2,
+48 -8
ファイルの表示
@@ -1,17 +1,23 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import { switchLocalePath } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
import type { LocaleSwitchRequest } from '@/lib/routeLocales'
import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async (
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{
{ url, title, tags, tagReferences, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
locale: LocaleCode = 'ja',
): Promise<{
posts: Post[]
count: number }> =>
await apiGet ('/posts', { params: {
await apiGet ('/posts', { locale, params: {
...(url && { url }),
...(title && { title }),
...(tags && { tags }),
...(tagReferences && { tag_refs: tagReferences }),
...(match && { match }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
@@ -24,7 +30,37 @@ export const fetchPosts = async (
...(order && { order }) } })
export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`)
export const switchPostFilterLocale = async (
{ location, locale, targetLocale }: LocaleSwitchRequest,
): Promise<string> => {
const query = new URLSearchParams (location.search)
const tags = query.get ('tags')
if (tags == null || tags.trim () === '')
return switchLocalePath (location, targetLocale)
const { references } = await apiGet<{ references: {
name: string
tagId?: number
externalTagId?: number
negative?: boolean
missing?: boolean }[] }> ('/posts/tag_filters', {
locale, params: { tags, target_locale: targetLocale,
tag_refs: query.get ('tag_refs') ?? undefined } })
query.set ('tags', references.map (reference => reference.name).join (' '))
query.set ('tag_refs', JSON.stringify (references.map (reference => ({
name: reference.name,
negative: reference.negative,
...(reference.tagId != null && { tag_id: reference.tagId }),
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
...(reference.missing && { missing: true }) }))))
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
}
export const fetchPost = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Post> => await apiGet (`/posts/${ id }`, { locale })
export const fetchPostChanges = async (
@@ -33,10 +69,12 @@ export const fetchPostChanges = async (
tag?: string
externalTag?: string
page: number
limit: number }): Promise<{
limit: number },
locale: LocaleCode = 'ja',
): Promise<{
versions: PostVersion[]
count: number }> =>
await apiGet ('/posts/versions', { params: { ...(post && { post }),
await apiGet ('/posts/versions', { locale, params: { ...(post && { post }),
...(tag && { tag }),
...(externalTag && {
external_tag: externalTag }),
@@ -54,7 +92,9 @@ export const updatePost = async (
{ baseVersionNo, force, merge }: {
baseVersionNo?: number
force?: boolean
merge?: boolean }) =>
merge?: boolean },
locale: LocaleCode = 'ja',
) =>
await apiPut<Post> (
`/posts/${ post.id }`,
{ title: post.title,
@@ -63,7 +103,7 @@ export const updatePost = async (
parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore },
{ params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
{ locale, params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
force: force ? '1' : '0',
merge: merge ? '1' : '0' } })
+26 -19
ファイルの表示
@@ -21,6 +21,9 @@ const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (),
fetchWikiPages: vi.fn (),
fetchWikiPageByReference: vi.fn (),
fetchWikiAutolinkCandidates: vi.fn (),
fetchWikiTag: vi.fn (),
}))
vi.mock ('@/lib/posts', () => postsApi)
@@ -45,12 +48,15 @@ describe ('prefetchForURL', () => {
wikiApi.fetchWikiPages.mockResolvedValue ([])
wikiApi.fetchWikiPage.mockResolvedValue ({ id: 1 })
wikiApi.fetchWikiPageByTitle.mockResolvedValue (null)
wikiApi.fetchWikiPageByReference.mockResolvedValue (null)
wikiApi.fetchWikiAutolinkCandidates.mockResolvedValue ([])
wikiApi.fetchWikiTag.mockResolvedValue (null)
})
it ('prefetches post indexes from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/posts?tags=a+b&match=any&page=2&limit=5&order=title%3Aasc',
'/ja/posts?tags=a+b&match=any&page=2&limit=5&order=title%3Aasc',
)
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
@@ -60,20 +66,20 @@ describe ('prefetchForURL', () => {
page: 2,
limit: 5,
order: 'title:asc',
}),
}), 'ja',
)
})
it ('prefetches post detail pages', async () => {
await prefetchForURL (qc (), 'http://localhost/posts/12')
await prefetchForURL (qc (), '/ja/posts/12')
expect (postsApi.fetchPost).toHaveBeenCalledWith ('12')
expect (postsApi.fetchPost).toHaveBeenCalledWith ('12', 'ja')
})
it ('prefetches tag indexes from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/tags?post=9&name=x&category=general&page=4&post_count_lte=10',
'/ja/tags?post=9&name=x&category=general&page=4&post_count_lte=10',
)
expect (tagsApi.fetchTags).toHaveBeenCalledWith (
@@ -83,17 +89,17 @@ describe ('prefetchForURL', () => {
category: 'general',
page: 4,
postCountLTE: 10,
}),
}), 'ja',
)
})
it ('prefetches nico tag indexes and their alias from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/tags/nico?name=source&linked_tag=destination'
'/ja/tags/nico?name=source&linked_tag=destination'
+ '&link_status=linked&page=3&limit=10',
)
await prefetchForURL (qc (), 'http://localhost/nico/tags?page=2')
await prefetchForURL (qc (), '/ja/nico/tags?page=2')
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (1, {
name: 'source',
@@ -102,7 +108,7 @@ describe ('prefetchForURL', () => {
page: 3,
limit: 10,
order: 'updated_at:desc',
})
}, 'ja')
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (2, {
name: '',
linkedTag: '',
@@ -110,28 +116,29 @@ describe ('prefetchForURL', () => {
page: 2,
limit: 20,
order: 'updated_at:desc',
})
}, 'ja')
})
it ('prefetches wiki show pages and related tag/post data', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({
wikiApi.fetchWikiPageByReference.mockResolvedValueOnce ({
id: 3,
title: 'Actual',
body: 'body',
})
await prefetchForURL (qc (), 'http://localhost/wiki/Alias')
await prefetchForURL (qc (), '/ja/wiki/Alias')
expect (wikiApi.fetchWikiPageByTitle).toHaveBeenCalledWith ('Alias', { version: undefined })
expect (wikiApi.fetchWikiPage).toHaveBeenCalledWith ('3', {})
expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual')
expect (wikiApi.fetchWikiPageByReference).toHaveBeenCalledWith (
'Alias', { version: undefined }, 'ja')
expect (wikiApi.fetchWikiPage).not.toHaveBeenCalled ()
expect (wikiApi.fetchWikiTag).toHaveBeenCalledWith ({ name: 'Actual' }, 'ja')
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({ tags: 'Actual', limit: 8 }),
expect.objectContaining ({ tags: 'Actual', limit: 8 }), 'ja',
)
})
it ('ignores routes without a prefetcher', async () => {
await prefetchForURL (qc (), 'http://localhost/unknown')
await prefetchForURL (qc (), '/ja/unknown')
expect (postsApi.fetchPosts).not.toHaveBeenCalled ()
expect (tagsApi.fetchTags).not.toHaveBeenCalled ()
@@ -141,14 +148,14 @@ describe ('prefetchForURL', () => {
it ('prefetches external tag post history without treating it as an internal tag', async () => {
await prefetchForURL (
qc (),
'http://localhost/posts/changes?external_tag=12&page=2&limit=50',
'/ja/posts/changes?external_tag=12&page=2&limit=50',
)
expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
externalTag: '12',
page: 2,
limit: 50,
})
}, 'ja')
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
})
+84 -67
ファイルの表示
@@ -5,17 +5,21 @@ import { getHelpArticle,
getHelpChanges,
getHelpDiff,
getHelpEditionContent,
getHelpSearchEntries } from '@/lib/help'
getHelpSearchEntries,
resolveHelpLanguage } from '@/lib/help'
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiPage,
fetchWikiPageByTitle,
import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiAutolinkCandidates,
fetchWikiPageByReference,
fetchWikiTag,
fetchWikiPages } from '@/lib/wiki'
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
import type { LocaleCode } from '@/lib/locales'
import type { Category, FetchPostsOrder, FetchTagsOrder, WikiTagReference } from '@/types'
type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
type Prefetcher = (qc: QueryClient, url: URL, locale: LocaleCode) => Promise<void>
const mPost = match<{ id: string }> ('/posts/:id')
const mWiki = match<{ title: string }> ('/wiki/:title')
@@ -28,25 +32,27 @@ const boolFromQuery = (value: string | null): boolean =>
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
const prefetchHelpIndex: Prefetcher = async qc => {
const prefetchHelpIndex: Prefetcher = async (qc, _url, locale) => {
await qc.prefetchQuery ({
queryKey: helpKeys.index ('ja'),
queryFn: () => getHelpSearchEntries ('ja'),
queryKey: helpKeys.index (localeLanguage (locale)),
queryFn: () => getHelpSearchEntries (localeLanguage (locale)),
staleTime: Infinity })
}
const prefetchHelpShow: Prefetcher = async (qc, url) => {
const prefetchHelpShow: Prefetcher = async (qc, url, locale) => {
const matched = mHelp (url.pathname)
if (!(matched))
return
const { identifier } = matched.params
const language = 'ja'
const article = await qc.fetchQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const language = resolveHelpLanguage (article, localeLanguage (locale))
if (language == null)
return
const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition
if (edition == null)
return
@@ -58,15 +64,15 @@ const prefetchHelpShow: Prefetcher = async (qc, url) => {
}
const prefetchHelpChanges: Prefetcher = async qc => {
const prefetchHelpChanges: Prefetcher = async (qc, _url, locale) => {
await qc.prefetchQuery ({
queryKey: helpKeys.changes ('ja'),
queryFn: () => getHelpChanges ('ja'),
queryKey: helpKeys.changes (localeLanguage (locale)),
queryFn: () => getHelpChanges (localeLanguage (locale)),
staleTime: Infinity })
}
const prefetchHelpDiff: Prefetcher = async (qc, url) => {
const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
const matched = mHelpDiff (url.pathname)
const fromValues = url.searchParams.getAll ('from')
const toValues = url.searchParams.getAll ('to')
@@ -76,23 +82,30 @@ const prefetchHelpDiff: Prefetcher = async (qc, url) => {
return
const { identifier } = matched.params
const article = await qc.fetchQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const language = resolveHelpLanguage (article, localeLanguage (locale))
if (language == null)
return
await qc.prefetchQuery ({
queryKey: helpKeys.diff (identifier, 'ja', from, to),
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
queryKey: helpKeys.diff (identifier, language, from, to),
queryFn: () => getHelpDiff (identifier, language, from, to),
staleTime: Infinity })
}
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
const title = url.searchParams.get ('title') ?? ''
await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ title }),
queryFn: () => fetchWikiPages ({ title }) })
queryKey: wikiKeys.index ({ title }, locale),
queryFn: () => fetchWikiPages ({ title }, locale) })
}
const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => {
const m = mWiki (url.pathname)
if (!(m))
return
@@ -101,35 +114,32 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
const version = url.searchParams.get ('version') || undefined
const wikiPage = await qc.fetchQuery ({
queryKey: wikiKeys.show (title, { version }),
queryFn: () => fetchWikiPageByTitle (title, { version }) })
queryKey: wikiKeys.show (title, { version }, locale),
queryFn: () => fetchWikiPageByReference (title, { version }, locale) })
if (wikiPage)
{
const effectiveId = String (wikiPage.id ?? '')
await qc.prefetchQuery ({
queryKey: wikiKeys.show (effectiveId, { }),
queryFn: () => fetchWikiPage (effectiveId, { } ) })
if (wikiPage.body)
{
await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ }),
queryFn: () => fetchWikiPages ({ }) })
}
}
if (wikiPage?.body)
{
const contentLanguage = wikiPage.languageCode
?? (wikiPage.resourceType === 'tag_description' ? localeLanguage (locale) : 'und')
const language = contentLanguage === 'und' ? 'ja' : contentLanguage
await qc.prefetchQuery ({
queryKey: wikiKeys.autolinkCandidates (language, locale),
queryFn: () => fetchWikiAutolinkCandidates (language, locale) })
}
const effectiveTitle = wikiPage?.title ?? title
const tagReference: WikiTagReference = (wikiPage != null || !/^\d+$/.test (title))
? { name: effectiveTitle } : { wikiId: title }
await qc.prefetchQuery ({
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })
const tag = await qc.fetchQuery ({
queryKey: wikiKeys.tag (tagReference, locale),
queryFn: () => fetchWikiTag (tagReference, locale) })
if (version)
return
const p = {
tags: effectiveTitle,
tags: wikiPage?.title ?? tag?.name ?? title,
match: 'all',
page: 1,
limit: 8,
@@ -143,12 +153,12 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
updatedTo: '',
order: 'original_created_at:desc' } as const
await qc.prefetchQuery ({
queryKey: postsKeys.index (p),
queryFn: () => fetchPosts (p) })
queryKey: postsKeys.index (p, locale),
queryFn: () => fetchPosts (p, locale) })
}
const prefetchPostsIndex: Prefetcher = async (qc, url) => {
const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
const tags = url.searchParams.get ('tags') ?? ''
const qURL = url.searchParams.get ('url') ?? ''
const title = url.searchParams.get ('title') ?? ''
@@ -165,16 +175,18 @@ const prefetchPostsIndex: Prefetcher = async (qc, url) => {
const keys = {
tags, match: m, page, limit, url: qURL, title,
...(url.searchParams.get ('tag_refs') && {
tagReferences: url.searchParams.get ('tag_refs')! }),
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order }
await qc.prefetchQuery ({
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
queryKey: postsKeys.index (keys, locale),
queryFn: () => fetchPosts (keys, locale) })
}
const prefetchPostShow: Prefetcher = async (qc, url) => {
const prefetchPostShow: Prefetcher = async (qc, url, locale) => {
const m = mPost (url.pathname)
if (!(m))
return
@@ -182,12 +194,12 @@ const prefetchPostShow: Prefetcher = async (qc, url) => {
const { id } = m.params
await qc.prefetchQuery ({
queryKey: postsKeys.show (id),
queryFn: () => fetchPost (id) })
queryKey: postsKeys.show (id, locale),
queryFn: () => fetchPost (id, locale) })
}
const prefetchPostChanges: Prefetcher = async (qc, url) => {
const prefetchPostChanges: Prefetcher = async (qc, url, locale) => {
const id = url.searchParams.get ('id')
const tag = url.searchParams.get ('tag')
const externalTag = url.searchParams.get ('external_tag')
@@ -197,23 +209,23 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => {
if (tag)
{
await qc.prefetchQuery ({
queryKey: tagsKeys.show (tag),
queryFn: () => fetchTag (tag) })
queryKey: tagsKeys.show (tag, locale),
queryFn: () => fetchTag (tag, locale) })
}
await qc.prefetchQuery ({
queryKey: postsKeys.changes ({ ...(id && { id }),
...(tag && { tag }),
...(externalTag && { externalTag }),
page, limit }),
page, limit }, locale),
queryFn: () => fetchPostChanges ({ ...(id && { id }),
...(tag && { tag }),
...(externalTag && { externalTag }),
page, limit }) })
page, limit }, locale) })
}
const prefetchTagsIndex: Prefetcher = async (qc, url) => {
const prefetchTagsIndex: Prefetcher = async (qc, url, locale) => {
const postRaw = url.searchParams.get ('post')
const post = postRaw ? Number (postRaw) : null
const name = url.searchParams.get ('name') ?? ''
@@ -237,12 +249,12 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
updatedFrom, updatedTo, deprecated, page, limit, order }
await qc.prefetchQuery ({
queryKey: tagsKeys.index (keys),
queryFn: () => fetchTags (keys) })
queryKey: tagsKeys.index (keys, locale),
queryFn: () => fetchTags (keys, locale) })
}
const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => {
const prefetchNicoTagsIndex: Prefetcher = async (qc, url, locale) => {
const keys = {
name: url.searchParams.get ('name') ?? '',
linkedTag: url.searchParams.get ('linked_tag') ?? '',
@@ -255,12 +267,12 @@ const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => {
| 'updated_at:asc' | 'updated_at:desc' }
await qc.prefetchQuery ({
queryKey: tagsKeys.nicoIndex (keys),
queryFn: () => fetchNicoTags (keys) })
queryKey: tagsKeys.nicoIndex (keys, locale),
queryFn: () => fetchNicoTags (keys, locale) })
}
const prefetchTagShow: Prefetcher = async (qc, url) => {
const prefetchTagShow: Prefetcher = async (qc, url, locale) => {
const m = mTag (url.pathname)
if (!(m))
return
@@ -268,19 +280,19 @@ const prefetchTagShow: Prefetcher = async (qc, url) => {
const { id } = m.params
await qc.prefetchQuery ({
queryKey: tagsKeys.show (id),
queryFn: () => fetchTag (id) })
queryKey: tagsKeys.show (id, locale),
queryFn: () => fetchTag (id, locale) })
}
const prefetchTagChanges: Prefetcher = async (qc, url) => {
const prefetchTagChanges: Prefetcher = async (qc, url, locale) => {
const id = url.searchParams.get ('id')
const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20)
await qc.prefetchQuery ({
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }),
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) })
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }, locale),
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }, locale) })
}
@@ -313,9 +325,14 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
const u = new URL (urlLike, location.origin)
if (u.origin !== location.origin)
return
const locale = localeFromPath (u.pathname) ?? 'ja'
u.pathname = unlocalisedPath (u.pathname)
const r = routePrefetchers.find (x => x.test (u))
if (!(r))
return
await r.run (qc, u)
await r.run (qc, u, locale)
}
+37 -5
ファイルの表示
@@ -1,14 +1,46 @@
import { describe, expect, it } from 'vitest'
import { QueryClient } from '@tanstack/react-query'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import type { FetchNicoTagsParams } from '@/types'
describe ('query keys', () => {
it ('uses stable namespaces for posts, tags, and wiki', () => {
expect (postsKeys.show ('3')).toEqual (['posts', '3'])
expect (postsKeys.related ('3')).toEqual (['related', '3'])
expect (tagsKeys.deerjikists ('7')).toEqual (['tags', 'deerjikists', '7'])
expect (postsKeys.show ('3')).toEqual (['posts', 'ja', '3'])
expect (postsKeys.related ('3')).toEqual (['related', 'ja', '3'])
expect (tagsKeys.deerjikists ('7')).toEqual (['tags', 'ja', 'deerjikists', '7'])
expect (wikiKeys.show ('Title', { version: '2' })).toEqual (
['wiki', 'Title', { version: '2' }],
)
['wiki', 'ja', 'Title', { version: '2' }])
})
it ('isolates the same resource by viewing locale', () => {
const client = new QueryClient ()
client.setQueryData (postsKeys.show ('3', 'ja'), { title: '日本語' })
client.setQueryData (postsKeys.show ('3', 'ain-Kana'), { title: 'アイヌ語' })
expect (client.getQueryData (postsKeys.show ('3', 'ja'))).toEqual ({ title: '日本語' })
expect (client.getQueryData (postsKeys.show ('3', 'ain-Kana')))
.toEqual ({ title: 'アイヌ語' })
client.clear ()
})
it ('invalidates both nico locales without invalidating ordinary tags', async () => {
const client = new QueryClient ()
const params: FetchNicoTagsParams = {
name: '', linkedTag: '', linkStatus: 'all', page: 1, limit: 20,
order: 'name:asc' }
const ja = tagsKeys.nicoIndex (params, 'ja')
const ain = tagsKeys.nicoIndex (params, 'ain-Kana')
const ordinary = tagsKeys.show ('3', 'ja')
for (const key of [ja, ain, ordinary])
client.setQueryData (key, [])
await client.invalidateQueries ({ queryKey: tagsKeys.nicoRoot })
expect (client.getQueryState (ja)?.isInvalidated).toBe (true)
expect (client.getQueryState (ain)?.isInvalidated).toBe (true)
expect (client.getQueryState (ordinary)?.isInvalidated).toBe (false)
client.clear ()
})
})
+120 -31
ファイルの表示
@@ -1,66 +1,145 @@
import type { LocaleCode } from '@/lib/locales'
import type {
FetchNicoTagsParams,
FetchMaterialsParams,
FetchPostsParams,
FetchTagsParams,
MaterialFilter,
} from '@/types'
WikiTagReference } from '@/types'
export const localesKeys = {
root: ['locales'] as const,
index: ['locales', 'index'] as const }
export const postsKeys = {
root: ['posts'] as const,
index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
show: (id: string) => ['posts', id] as const,
related: (id: string) => ['related', id] as const,
changes: (p: { post?: string
index: (
p: FetchPostsParams,
locale: LocaleCode = 'ja',
) => ['posts', locale, 'index', p] as const,
show: (
id: string,
locale: LocaleCode = 'ja',
) => ['posts', locale, id] as const,
related: (
id: string,
locale: LocaleCode = 'ja',
) => ['related', locale, id] as const,
changes: (
p: { post?: string
tag?: string
externalTag?: string
page: number
limit: number }) =>
['posts', 'changes', p] as const }
limit: number },
locale: LocaleCode = 'ja',
) =>
['posts', locale, 'changes', p] as const }
export const gekanatorKeys = {
root: ['gekanator'] as const,
posts: () => ['gekanator', 'posts'] as const,
questions: () => ['gekanator', 'questions'] as const,
extraQuestions: (gameId: number, nonce: string) =>
['gekanator', 'games', gameId, 'extra-questions', nonce] as const }
posts: (
locale: LocaleCode = 'ja',
) => ['gekanator', locale, 'posts'] as const,
questions: (
locale: LocaleCode = 'ja',
) => ['gekanator', locale, 'questions'] as const,
extraQuestions: (
gameId: number,
nonce: string,
locale: LocaleCode = 'ja',
) =>
['gekanator', locale, 'games', gameId, 'extra-questions', nonce] as const }
export const tagsKeys = {
root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
index: (
p: FetchTagsParams,
locale: LocaleCode = 'ja',
) => ['tags', locale, 'index', p] as const,
nicoRoot: ['tags', 'nico'] as const,
nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const,
externalShow: (id: string) => ['tags', 'nico', id] as const,
show: (name: string) => ['tags', name] as const,
changes: (p: { id?: string; page: number; limit: number }) =>
['tags', 'changes', p] as const,
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
nicoIndex: (
p: FetchNicoTagsParams,
locale: LocaleCode = 'ja',
) => ['tags', 'nico', locale, 'index', p] as const,
externalShow: (
id: string,
locale: LocaleCode = 'ja',
) => ['tags', 'nico', locale, id] as const,
show: (
name: string,
locale: LocaleCode = 'ja',
) => ['tags', locale, name] as const,
changes: (
p: { id?: string; page: number; limit: number },
locale: LocaleCode = 'ja',
) =>
['tags', locale, 'changes', p] as const,
deerjikists: (
id: string,
locale: LocaleCode = 'ja',
) => ['tags', locale, 'deerjikists', id] as const }
export const materialsKeys = {
root: ['materials'] as const,
index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const,
changes: (p: {
index: (
p: FetchMaterialsParams,
locale: LocaleCode = 'ja',
) => ['materials', locale, 'index', p] as const,
changes: (
p: {
materialId?: string
tag?: string
eventType?: string
page: number
limit: number
}) => ['materials', 'changes', p] as const,
byTagName: (name: string, materialFilter: MaterialFilter) =>
['materials', 'tag', name, materialFilter] as const,
show: (id: string) => ['materials', id] as const,
suppressions: () => ['materials', 'suppressions'] as const,
tree: (p: {
},
locale: LocaleCode = 'ja',
) => ['materials', locale, 'changes', p] as const,
byTagName: (
name: string,
materialFilter: MaterialFilter,
locale: LocaleCode = 'ja',
) =>
['materials', locale, 'tag', name, materialFilter] as const,
show: (
id: string,
locale: LocaleCode = 'ja',
) => ['materials', locale, id] as const,
suppressions: (
locale: LocaleCode = 'ja',
) => ['materials', locale, 'suppressions'] as const,
tree: (
p: {
parentId?: number | null
materialFilter: MaterialFilter
}) => ['materials', 'tree', p] as const,
unclassified: (p: { page?: number; limit?: number } = { }) =>
['materials', 'unclassified', p] as const}
},
locale: LocaleCode = 'ja',
) => ['materials', locale, 'tree', p] as const,
unclassified: (
p: { page?: number; limit?: number } = { },
locale: LocaleCode = 'ja',
) =>
['materials', locale, 'unclassified', p] as const}
export const wikiKeys = {
root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }
tag: (reference: WikiTagReference, locale: LocaleCode) =>
['wiki', locale, 'tag', reference] as const,
diff: (id: string, p: { from: string | null; to: string | null }, locale: LocaleCode) =>
['wiki', locale, 'diff', id, p] as const,
autolinkCandidates: (language: string, locale: LocaleCode) =>
['wiki', 'autolink', locale, language] as const,
index: (
p: { title?: string },
locale: LocaleCode = 'ja',
) => ['wiki', locale, 'index', p] as const,
show: (
title: string,
p: { version?: string },
locale: LocaleCode = 'ja',
) => ['wiki', locale, title, p] as const,
changes: (p: { id?: string }, locale: LocaleCode = 'ja') =>
['wiki', locale, 'changes', 'index', p] as const }
export const helpKeys = {
root: ['help'] as const,
@@ -72,3 +151,13 @@ export const helpKeys = {
diff: (identifier: string, language: string, from: string, to: string) =>
['help', 'diff', identifier, language, from, to] as const,
changes: (language: string) => ['help', 'changes', language] as const }
export const sessionKeys = {
current: ['session', 'current'] as const,
settings: (id: number) => ['session', id, 'settings'] as const,
themeSlots: (id: number) => ['session', id, 'themeSlots'] as const }
export const theatresKeys = {
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
programmes: (id: string, locale: LocaleCode) =>
['theatres', locale, id, 'programmes'] as const }
+76 -51
ファイルの表示
@@ -4,65 +4,90 @@ import remarkWikiAutolink from '@/lib/remark-wiki-autolink'
import type { Root } from 'mdast'
describe ('remarkWikiAutolink', () => {
it ('links matching wiki page names and prefers longer matches', () => {
const tree: Root = {
type: 'root',
children: [{
type: 'paragraph',
children: [{ type: 'text', value: '虹夏 and 虹' }],
}],
}
import type { WikiAutolinkCandidate } from '@/types'
remarkWikiAutolink (['虹', '虹夏']) (tree)
const candidate = (
name: string,
resourceId: number,
overrides: Partial<WikiAutolinkCandidate> = {},
): WikiAutolinkCandidate => ({
name, resourceId, tagId: resourceId + 100, tagDescriptionId: resourceId,
resourceType: 'tag_description', languageCode: 'ja', localeCode: 'ain-Kana',
...overrides })
const paragraph = (value: string): Root => ({
type: 'root', children: [{
type: 'paragraph', children: [{ type: 'text', value }] }] })
describe ('remarkWikiAutolink', () => {
it ('prefers longer names and keeps the viewing locale for Japanese text', () => {
const tree = paragraph ('虹夏 and 虹')
remarkWikiAutolink ([candidate ('虹', 1), candidate ('虹夏', 2)],
'ja', 'ain-Kana') (tree)
expect (tree.children[0]).toMatchObject ({
type: 'paragraph',
children: [
{
type: 'link',
url: '/wiki/%E8%99%B9%E5%A4%8F',
children: [{ type: 'text', value: '虹夏' }],
},
{ type: 'text', value: ' and ' },
{
type: 'link',
url: '/wiki/%E8%99%B9',
children: [{ type: 'text', value: '虹' }],
},
],
})
children: [
{ type: 'link', url: '/ain-Kana/wiki/2',
children: [{ type: 'text', value: '虹夏' }] },
{ type: 'text', value: ' and ' },
{ type: 'link', url: '/ain-Kana/wiki/1',
children: [{ type: 'text', value: '虹' }] }] })
})
it ('does not link text inside existing links or code', () => {
it ('does not rewrite existing links, code or images', () => {
const tree: Root = {
type: 'root',
children: [
{
type: 'paragraph',
children: [{
type: 'link',
url: '/existing',
children: [{ type: 'text', value: '虹' }],
}],
},
{
type: 'code',
value: '虹',
},
],
}
type: 'root', children: [
{ type: 'paragraph', children: [
{ type: 'link', url: '/existing', children: [{ type: 'text', value: '虹' }] },
{ type: 'inlineCode', value: '虹' },
{ type: 'image', url: '/image', alt: '虹' }] },
{ type: 'code', value: '虹' }] }
const original = structuredClone (tree)
remarkWikiAutolink (['虹']) (tree)
remarkWikiAutolink ([candidate ('虹', 1)], 'ja', 'ain-Kana') (tree)
expect (tree).toEqual (original)
})
it ('links an alias to its description rather than its tag ID or matched name', () => {
const tree = paragraph ('別名')
remarkWikiAutolink ([candidate ('別名', 9, { tagId: 123 })],
'ja', 'ain-Kana') (tree)
expect (tree.children[0]).toMatchObject ({
type: 'paragraph',
children: [{
type: 'link',
url: '/existing',
children: [{ type: 'text', value: '虹' }],
}],
})
expect (tree.children[1]).toMatchObject ({ type: 'code', value: '虹' })
children: [{ type: 'link', url: '/ain-Kana/wiki/9' }] })
})
it ('leaves ambiguous names and other locales or languages unlinked', () => {
const tree = paragraph ('虹 名前 name')
const original = structuredClone (tree)
remarkWikiAutolink ([candidate ('虹', 1), candidate ('虹', 2),
candidate ('名前', 3, { localeCode: 'ja' }),
candidate ('name', 4, { languageCode: 'ain' })], 'ja', 'ain-Kana') (tree)
expect (tree).toEqual (original)
})
it ('links Ainu words only at word boundaries', () => {
const tree = paragraph ('チャペ チャペ。チャペア')
remarkWikiAutolink ([candidate ('チャペ', 5, { languageCode: 'ain' })],
'ain', 'ain-Kana') (tree)
expect (tree.children[0]).toMatchObject ({
children: [
{ type: 'link', url: '/ain-Kana/wiki/5' },
{ type: 'text', value: ' ' },
{ type: 'link', url: '/ain-Kana/wiki/5' },
{ type: 'text', value: '。チャペア' }] })
})
it ('preserves tag-group resource identity', () => {
const tree = paragraph ('group')
remarkWikiAutolink ([candidate ('group', 8, {
resourceType: 'tag_group', tagId: null, tagDescriptionId: null })],
'ja', 'ain-Kana') (tree)
expect (tree.children[0]).toMatchObject ({
children: [{ type: 'link', url: '/ain-Kana/wiki/8' }] })
})
})
+114 -84
ファイルの表示
@@ -1,101 +1,131 @@
import type { Content, Parent, Root, RootContent } from 'mdast'
import type { WikiAutolinkCandidate } from '@/types'
import type { LocaleCode } from '@/lib/locales'
const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')
export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) => {
const names = [...pageNames].sort ((a, b) => b.length - a.length)
const wordCharacter = /[\p{L}\p{M}\p{N}_]/u
const excluded = ['link', 'linkReference', 'image', 'imageReference',
'code', 'inlineCode', 'html']
if (names.length === 0)
const edgeCharacter = (node: Content, first: boolean): string => {
if (node.type === 'text' || node.type === 'inlineCode')
{
const characters = Array.from (node.value)
return (first ? characters[0] : characters.at (-1)) ?? ''
}
if ('children' in node)
{
const children = first ? node.children : [...node.children].reverse ()
for (const child of children)
{
return () => {
;
}
const character = edgeCharacter (child, first)
if (character !== '')
return character
}
return ''
}
return '\u3000'
}
const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g')
const remarkWikiAutoLink = (
candidates: WikiAutolinkCandidate[],
language: string,
locale: LocaleCode,
): ((tree: Root) => void) => {
const targets = new Map<string, WikiAutolinkCandidate> ()
const ambiguous = new Set<string> ()
for (const candidate of candidates)
{
if (candidate.languageCode !== language || candidate.localeCode !== locale
|| candidate.name.length === 0)
continue
const previous = targets.get (candidate.name)
if (previous != null
&& (previous.resourceType !== candidate.resourceType
|| previous.resourceId !== candidate.resourceId
|| previous.tagId !== candidate.tagId))
{
ambiguous.add (candidate.name)
continue
}
targets.set (candidate.name, candidate)
}
const names = [...targets.keys ()].sort ((a, b) => b.length - a.length)
if (names.length === 0)
return () => { }
const expression = new RegExp (names.map (escapeForRegExp).join ('|'), 'gu')
const ainu = language === 'ain'
return (tree: Root) => {
const edits: { parent: Parent; index: number; parts: RootContent[] }[] = []
const walk = (node: Content | Root, ancestors: Parent[]) => {
if (!(node) || (typeof node !== 'object'))
const walk = (
node: Content | Root,
parent?: Parent,
preceding = '',
following = '',
) => {
if (excluded.includes (node.type))
return
if (!(ancestors.some (ancestor => ['link',
'linkReference',
'image',
'imageReference',
'code',
'inlineCode'].includes (ancestor?.type)))
&& (node.type === 'text'))
{
const value = node.value ?? ''
if (value)
{
re.lastIndex = 0
let m: RegExpExecArray | null
let last = 0
const parts: RootContent[] = []
while ((m = re.exec (value)) !== null)
{
const start = m.index
const end = start + m[0].length
if (start > last)
parts.push ({ type: 'text', value: value.slice (last, start) })
const name = m[1]
parts.push ({ type: 'link',
url: `${ basePath }/${ encodeURIComponent (name) }`,
title: null,
children: [{ type: 'text', value: name }],
data: { hProperties: { 'data-wiki': '1' } } })
last = end
}
if (parts.length)
{
if (last < value.length)
parts.push ({ type: 'text', value: value.slice (last) })
const parent = ancestors[ancestors.length - 1]
if (parent && Array.isArray (parent.children))
{
const index = parent.children.indexOf (node)
if (index >= 0)
edits.push ({ parent, index, parts })
}
}
}
}
const maybeChidren = 'children' in node ? node.children : undefined
if (Array.isArray (maybeChidren))
{
const parent = node as Parent
for (let i = 0; i < maybeChidren.length; ++i)
{
const child: Content | undefined = maybeChidren[i]
if (!(child))
continue
walk (child, ancestors.concat (parent))
}
}
}
walk (tree, [])
for (let i = edits.length - 1; i >= 0; --i)
if (node.type === 'text' && parent != null)
{
const { parent, index, parts } = edits[i]
if (!(parent) || !(Array.isArray (parent.children)))
continue
if (0 <= index && index < parent.children.length)
parent.children.splice (index, 1, ...parts)
const value = node.value
let last = 0
const parts: RootContent[] = []
for (const match of value.matchAll (expression))
{
const start = match.index
const end = start + match[0].length
const candidate = targets.get (match[0])
const before = Array.from (value.slice (0, start)).at (-1) ?? preceding
const after = Array.from (value.slice (end))[0] ?? following
if (candidate == null || ambiguous.has (match[0])
|| (ainu && (wordCharacter.test (before) || wordCharacter.test (after))))
continue
if (start > last)
parts.push ({ type: 'text', value: value.slice (last, start) })
parts.push ({
type: 'link',
url: `/${locale}/wiki/${encodeURIComponent (candidate.resourceId)}`,
title: null,
children: [{ type: 'text', value: match[0] }],
data: { hProperties: { 'data-wiki': '1' } } })
last = end
}
if (parts.length > 0)
{
if (last < value.length)
parts.push ({ type: 'text', value: value.slice (last) })
edits.push ({ parent, index: parent.children.indexOf (node), parts })
}
return
}
if ('children' in node)
{
const inline = ['paragraph', 'heading', 'emphasis', 'strong',
'delete', 'tableCell'].includes (node.type)
for (let index = 0; index < node.children.length; ++index)
{
const before = index > 0
? edgeCharacter (node.children[index - 1], false) : preceding
const after = index + 1 < node.children.length
? edgeCharacter (node.children[index + 1], true) : following
walk (node.children[index], node as Parent,
inline ? before : '', inline ? after : '')
}
}
}
walk (tree)
for (const { parent, index, parts } of edits.reverse ())
parent.children.splice (index, 1, ...parts)
}
}
export default remarkWikiAutoLink
+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)
+18
ファイルの表示
@@ -0,0 +1,18 @@
export type RenderContext = {
pathname: string
origin: string
apiBaseURL?: string
cookie?: string
setCookies?: string[]
csrf?: { token?: string; pending?: Promise<string> }
forwardedFor?: string
status?: number }
// 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 }
}
+35
ファイルの表示
@@ -0,0 +1,35 @@
import type { QueryClient } from '@tanstack/react-query'
import type { IndexRouteObject, Location, NonIndexRouteObject, Params } from 'react-router-dom'
import type { LocaleCode } from '@/lib/locales'
export type PagePreparation = {
client: QueryClient
params: Readonly<Params>
locale: LocaleCode
searchParams: URLSearchParams }
export type LocaleSwitchRequest = {
location: Pick<Location, 'pathname' | 'search' | 'hash'>
params: Readonly<Params>
locale: LocaleCode
targetLocale: LocaleCode }
export type SitemapSource =
'static' | 'help' | 'posts' | 'tags' | 'deerjikists' | 'materials'
| 'descriptions' | 'tag_groups' | 'theatres'
export type PageRouteHandle = {
preload?: (locale: LocaleCode) => Promise<unknown>
prepare?: (request: PagePreparation) => Promise<number>
switchLocale?: (request: LocaleSwitchRequest) => Promise<string>
locale?: LocaleCode
robots?: 'noindex'
authentication?: 'user'
sitemap?: SitemapSource[]
redirectTo?: string
notFound?: boolean }
export type PageRoute = { handle?: PageRouteHandle } & (
Omit<IndexRouteObject, 'handle'>
| (Omit<NonIndexRouteObject, 'handle' | 'children'> & { children?: PageRoute[] }))
+4 -2
ファイルの表示
@@ -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 => {
@@ -790,7 +791,8 @@ export const seedClientThemeMode = (theme: UserSettings['theme']): void => {
const prefersDarkTheme = (): boolean =>
window.matchMedia ('(prefers-color-scheme: dark)').matches
typeof window !== 'undefined'
&& window.matchMedia ('(prefers-color-scheme: dark)').matches
export const resolveThemeMode = (
+152
ファイルの表示
@@ -0,0 +1,152 @@
import { generatePath } from 'react-router-dom'
import { pageRoutes } from '@/App'
import { apiGet, isApiError } from '@/lib/api'
import { parseHelpEdition } from '@/lib/help'
import { fetchLocales, isLocaleCode, localeLanguage } from '@/lib/locales'
import { withRenderContext } from '@/lib/renderContext.server'
import type { LocaleCode } from '@/lib/locales'
import type { PageRoute, SitemapSource } from '@/lib/routeLocales'
type SitemapRoute = { path: string; source: SitemapSource }
type SitemapResponse = { status: number; body: string }
type ResourceCounts = {
resources: { resource: SitemapSource; count: number }[]
pageSize: number }
const PAGE_SIZE = 1_000
const sitemapRoutes = (locale: LocaleCode): SitemapRoute[] => {
const collect = (routes: PageRoute[], parent = ''): SitemapRoute[] =>
routes.flatMap (route => {
const handle = route.handle
if (handle?.robots === 'noindex' || handle?.authentication != null
|| handle?.redirectTo != null || handle?.notFound
|| (handle?.locale != null && handle.locale !== locale))
return []
const path = [parent, route.path].filter (Boolean).join ('/')
return [
...(handle?.sitemap ?? []).map (source => ({ path, source })),
...collect (route.children ?? [], path)]
})
return collect (pageRoutes (null, () => { }))
}
const helpIdentifiers = async (locale: LocaleCode): Promise<string[]> => {
const { default: articles } = await import ('virtual:help/articles')
const language = localeLanguage (locale)
return Object.values (articles).filter (article => {
const edition = article.languages[language]
return edition != null && edition.publishedEditions.includes (edition.currentEdition)
&& parseHelpEdition (edition.currentEdition) != null
}).map (article => article.identifier).sort ()
}
const escapeXML = (value: string): string =>
value.replace (/&/g, '&amp;').replace (/</g, '&lt;').replace (/>/g, '&gt;')
.replace (/"/g, '&quot;').replace (/'/g, '&apos;')
const sitemapXML = (urls: string[], index = false): string => {
if (urls.length > 50_000)
throw new Error ('Sitemap URL limit exceeded')
const root = index ? 'sitemapindex' : 'urlset'
const item = index ? 'sitemap' : 'url'
return '<?xml version="1.0" encoding="UTF-8"?>\n'
+ `<${root} xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`
+ urls.map (url => ` <${item}><loc>${escapeXML (url)}</loc></${item}>`).join ('\n')
+ `\n</${root}>\n`
}
export const renderSitemap = async (
address: string,
origin: string,
apiBaseURL: string,
): Promise<SitemapResponse> =>
withRenderContext ({ pathname: address, origin, apiBaseURL }, async () => {
const url = new URL (address, origin)
const index = url.pathname === '/sitemap.xml'
const matched = url.pathname.match (/^\/sitemap-(.+)-([a-z_]+)-([1-9]\d*)\.xml$/)
if (!index && matched == null)
return { status: 404, body: '' }
// Sitemap requests are always public; incoming user cookies are not forwarded.
const available = await fetchLocales ()
const locales = available.locales.map (entry => entry.code).filter (isLocaleCode)
if (index)
{
const urls: string[] = []
for (const locale of locales)
{
const sources = new Set (sitemapRoutes (locale).map (route => route.source))
const counts = await apiGet<ResourceCounts> ('/sitemap_entries', { locale })
for (const source of sources)
{
const count =
source === 'static'
? 1
: (source === 'help'
? (await helpIdentifiers (locale)).length
: counts.resources.find (entry => entry.resource === source)?.count ?? 0)
const pageSize = source === 'help' ? PAGE_SIZE : counts.pageSize
if (!Number.isSafeInteger (pageSize) || pageSize <= 0
|| !Number.isSafeInteger (count) || count < 0)
throw new Error ('Invalid sitemap pagination')
const pages = Math.ceil (count / pageSize)
if (urls.length + pages > 50_000)
throw new Error ('Sitemap index limit exceeded')
for (let page = 1; page <= pages; ++page)
urls.push (new URL (`/sitemap-${locale}-${source}-${page}.xml`, origin).href)
}
}
return { status: 200, body: sitemapXML (urls, true) }
}
const [, locale, source, pageValue] = matched!
const page = Number (pageValue)
if (!isLocaleCode (locale) || !locales.includes (locale)
|| !Number.isSafeInteger (page))
return { status: 404, body: '' }
const routes = sitemapRoutes (locale).filter (route => route.source === source)
if (routes.length === 0)
return { status: 404, body: '' }
const parameters: Record<string, string>[] = []
if (source === 'static')
{
if (page !== 1)
return { status: 404, body: '' }
parameters.push ({ })
}
else if (source === 'help')
{
const identifiers = await helpIdentifiers (locale)
parameters.push (...identifiers.slice ((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
.map (identifier => ({ identifier: encodeURIComponent (identifier) })))
}
else
{
try
{
const { ids } = await apiGet<{ ids: number[] }> (
`/sitemap_entries/${source}`, { locale, params: { page } })
parameters.push (...ids.map (id => ({ id: String (id), title: String (id) })))
}
catch (error)
{
if (isApiError (error) && error.response?.status === 404)
return { status: 404, body: '' }
throw error
}
}
if (parameters.length === 0)
return { status: 404, body: '' }
const urls = routes.flatMap (route => parameters.map (params =>
new URL (generatePath (`/${locale}/${route.path}`, params), origin).href))
return { status: 200, body: sitemapXML ([...new Set (urls)]) }
})
+3 -2
ファイルの表示
@@ -45,6 +45,7 @@ describe ('tags API functions', () => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags',
{
locale: 'ja',
params: {
name: '虹',
category: 'character',
@@ -68,7 +69,7 @@ describe ('tags API functions', () => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags',
{ params: expect.objectContaining ({ deprecated: expected }) },
{ locale: 'ja', params: expect.objectContaining ({ deprecated: expected }) },
)
})
@@ -91,6 +92,6 @@ describe ('tags API functions', () => {
api.apiGet.mockRejectedValueOnce (error)
await expect (fetchTagByName ('名前/#%')).rejects.toBe (error)
expect (api.apiGet).toHaveBeenLastCalledWith (
`/tags/name/${ encodeURIComponent ('名前/#%') }`)
`/tags/name/${ encodeURIComponent ('名前/#%') }`, { locale: 'ja' })
})
})
+32 -14
ファイルの表示
@@ -2,6 +2,7 @@ import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { Deerjikist,
FetchNicoTagsParams,
FetchTagsParams,
@@ -13,9 +14,11 @@ import type { Deerjikist,
export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, deprecated,
page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[]
page, limit, order }: FetchTagsParams,
locale: LocaleCode = 'ja',
): Promise<{ tags: Tag[]
count: number }> =>
await apiGet ('/tags', { params: {
await apiGet ('/tags', { locale, params: {
...(post != null && { post }),
...(name && { name }),
...(category && { category }),
@@ -32,9 +35,11 @@ export const fetchTags = async (
export const fetchNicoTags = async (
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams): Promise<{ tags: NicoTag[]
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams,
locale: LocaleCode = 'ja',
): Promise<{ tags: NicoTag[]
count: number }> =>
await apiGet ('/tags/nico', { params: {
await apiGet ('/tags/nico', { locale, params: {
page,
limit,
name,
@@ -43,10 +48,13 @@ export const fetchNicoTags = async (
order } })
export const fetchTag = async (id: string): Promise<Tag | null> => {
export const fetchTag = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Tag | null> => {
try
{
return await apiGet (`/tags/${ id }`)
return await apiGet (`/tags/${ id }`, { locale })
}
catch
{
@@ -55,10 +63,13 @@ export const fetchTag = async (id: string): Promise<Tag | null> => {
}
export const fetchExternalTag = async (id: string): Promise<Tag | null> => {
export const fetchExternalTag = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Tag | null> => {
try
{
return await apiGet (`/tags/nico/${ id }`)
return await apiGet (`/tags/nico/${ id }`, { locale })
}
catch
{
@@ -67,10 +78,13 @@ export const fetchExternalTag = async (id: string): Promise<Tag | null> => {
}
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
export const fetchTagByName = async (
name: string,
locale: LocaleCode = 'ja',
): Promise<Tag | null> => {
try
{
return await apiGet (`/tags/name/${ encodeURIComponent (name) }`)
return await apiGet (`/tags/name/${ encodeURIComponent (name) }`, { locale })
}
catch (error)
{
@@ -84,12 +98,16 @@ export const fetchTagChanges = async (
{ id, page, limit }: {
id?: string
page: number
limit: number }): Promise<{
limit: number },
locale: LocaleCode = 'ja',
): Promise<{
versions: TagVersion[]
count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } })
await apiGet ('/tags/versions', { locale, params: { ...(id && { id }), page, limit } })
export const fetchDeerjikistsByTag = async (
id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
await apiGet (`/tags/${ id }/deerjikists`)
id: string,
locale: LocaleCode = 'ja',
): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
await apiGet (`/tags/${ id }/deerjikists`, { locale })
+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 ())
}
+9
ファイルの表示
@@ -0,0 +1,9 @@
import { useSyncExternalStore } from 'react'
const subscribe = () => () => { }
const clientSnapshot = () => true
const serverSnapshot = () => false
export const useHydrated = (): boolean =>
useSyncExternalStore (subscribe, clientSnapshot, serverSnapshot)
+8 -3
ファイルの表示
@@ -7,8 +7,9 @@ import {
useRef,
useState,
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocaleNavigate } from '@/lib/useLocale'
import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
import {
DEFAULT_KEY_BINDINGS,
@@ -95,15 +96,19 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
useState<ClientKeyboardSettings> ({ enabled: true, bindings: { } })
const [registeredHandlers, setRegisteredHandlers] =
useState<Record<number, ShortcutHandlers>> ({ })
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
const registrationIdRef = useRef (0)
useEffect (() => {
setKeyboardSettingsState (getClientKeyboardSettings ())
}, [])
const activeScope = useMemo<ShortcutScope> (
() => resolveShortcutScope (location.pathname),
[location.pathname],
+27
ファイルの表示
@@ -0,0 +1,27 @@
import { useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { localiseTo, resolveViewingLocale } from '@/lib/locales'
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
import type { LocaleCode } from '@/lib/locales'
export const useLocale = (): LocaleCode => {
const { locale } = useParams ()
const location = useLocation ()
return resolveViewingLocale (location.pathname, locale)
}
export const useLocaleNavigate = (): NavigateFunction => {
const navigate = useNavigate ()
const locale = useLocale ()
return useCallback ((to: To | number, options?: NavigateOptions) => {
if (typeof to === 'number')
navigate (to)
else
navigate (localiseTo (to, locale), options)
}, [locale, navigate])
}
+25
ファイルの表示
@@ -0,0 +1,25 @@
import { createElement, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import { useLocale } from '@/lib/useLocale'
export const useUITranslation = (namespace = 'common') => {
const locale = useLocale ()
const { i18n } = useTranslation (namespace, { lng: locale })
const message = useCallback ((key: string, values?: Record<string, string | number>) => {
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)
return createElement (LocalisedText, {
language: result.language, children: result.text })
}, [message])
const siteTitle = message ('shared:labels.brand')
return { message, label, siteTitle }
}
+7 -3
ファイルの表示
@@ -10,6 +10,7 @@ import {
import { useBlocker } from 'react-router-dom'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, PropsWithChildren } from 'react'
@@ -34,6 +35,7 @@ const UnsavedChangesGuardContext =
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const { label } = useUITranslation ('settings')
const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
const bypassNextNavigationRef = useRef<symbol | null> (null)
const [revision, setRevision] = useState (0)
@@ -99,8 +101,10 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
try
{
const confirmed = await dialogue.confirm ({
title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
title: label ('unsaved.leavePage'),
confirmText: label ('unsaved.discardAndMove'),
cancelText: label ('actions.cancel'),
closeLabel: label ('actions.close'),
variant: 'danger' })
if (!(confirmed))
return false
@@ -111,7 +115,7 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
{
dialogueOpenRef.current = false
}
}, [dialogue, hasUnsavedChanges])
}, [dialogue, hasUnsavedChanges, label])
useEffect (() => {
if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
+69
ファイルの表示
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted (() => ({ apiGet: vi.fn (), apiPost: vi.fn () }))
vi.mock ('@/lib/api', () => ({ ...api,
isApiError: (error: { isAxiosError?: boolean }) => error.isAxiosError === true }))
const user = { id: 1, name: 'Cookie user', role: 'guest', inheritanceCode: 'current-code' }
const noSession = { isAxiosError: true, response: { status: 401 } }
describe ('Cookie session initialisation', () => {
beforeEach (() => {
vi.resetModules ()
vi.clearAllMocks ()
api.apiGet.mockReset ()
api.apiPost.mockReset ()
localStorage.clear ()
})
it ('keeps Cookie identity and updates an older recovery code', async () => {
localStorage.setItem ('user_code', 'old-code')
api.apiGet.mockResolvedValueOnce (user)
const { initialiseUserSession } = await import ('@/lib/users')
await expect (initialiseUserSession ()).resolves.toEqual (user)
expect (api.apiGet).toHaveBeenCalledWith ('/users/me')
expect (api.apiPost).not.toHaveBeenCalled ()
expect (localStorage.getItem ('user_code')).toBe ('current-code')
})
it ('restores from storage only after Cookie authentication returns 401', async () => {
localStorage.setItem ('user_code', 'backup-code')
api.apiGet.mockRejectedValueOnce (noSession)
api.apiPost.mockResolvedValueOnce ({ valid: true, user })
const { initialiseUserSession } = await import ('@/lib/users')
await expect (initialiseUserSession ()).resolves.toEqual (user)
expect (api.apiPost).toHaveBeenCalledExactlyOnceWith (
'/users/verify', { code: 'backup-code', restore: true })
})
it ('creates an account after a recovery code is rejected', async () => {
localStorage.setItem ('user_code', 'expired-code')
api.apiGet.mockRejectedValueOnce (noSession)
api.apiPost.mockResolvedValueOnce ({ valid: false })
api.apiPost.mockResolvedValueOnce ({ code: user.inheritanceCode, user })
const { initialiseUserSession } = await import ('@/lib/users')
await expect (initialiseUserSession ()).resolves.toEqual (user)
expect (api.apiPost).toHaveBeenLastCalledWith ('/users')
})
it.each ([403, 503]) ('does not replace a session when the API returns %s', async status => {
localStorage.setItem ('user_code', 'backup-code')
const error = { isAxiosError: true, response: { status } }
api.apiGet.mockRejectedValueOnce (error)
const { initialiseUserSession } = await import ('@/lib/users')
await expect (initialiseUserSession ()).rejects.toBe (error)
expect (api.apiPost).not.toHaveBeenCalled ()
expect (localStorage.getItem ('user_code')).toBe ('backup-code')
})
it ('shares concurrent initialisation to avoid duplicate account creation', async () => {
api.apiGet.mockRejectedValueOnce (noSession)
api.apiPost.mockResolvedValueOnce ({ code: user.inheritanceCode, user })
const { initialiseUserSession } = await import ('@/lib/users')
await Promise.all ([initialiseUserSession (), initialiseUserSession ()])
expect (api.apiGet).toHaveBeenCalledTimes (1)
expect (api.apiPost).toHaveBeenCalledExactlyOnceWith ('/users')
})
})
+64
ファイルの表示
@@ -1,6 +1,70 @@
import { apiGet, apiPost, isApiError } from '@/lib/api'
import type { User, UserRole } from '@/types'
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
let pendingSession: Promise<User> | null = null
const storedUserCode = (): string | null => {
try
{
return localStorage.getItem ('user_code')
}
catch
{
return null
}
}
export const rememberUserCode = (code: string): void => {
try
{
localStorage.setItem ('user_code', code)
}
catch
{
// A missing recovery copy must not invalidate the authenticated Cookie.
}
}
const restoreUserSession = async (): Promise<User> => {
try
{
return await apiGet<User> ('/users/me')
}
catch (error)
{
if (!isApiError (error) || error.response?.status !== 401)
throw error
}
const code = storedUserCode ()
if (code)
{
const data = await apiPost<{ valid: boolean; user: User }> (
'/users/verify', { code, restore: true })
if (data.valid)
return data.user
}
const data = await apiPost<{ code: string; user: User }> ('/users')
return data.user
}
export const initialiseUserSession = (): Promise<User> => {
if (typeof window === 'undefined')
throw new Error ('Browser session initialisation is client-only')
pendingSession ??= restoreUserSession ().then (user => {
rememberUserCode (user.inheritanceCode)
return user
}).finally (() => {
pendingSession = null
})
return pendingSession
}
export const canEditContent = (
+79 -19
ファイルの表示
@@ -1,8 +1,13 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { i18n } from '@/lib/i18n'
import { currentViewingLocale } from '@/lib/locales'
import type { ClassValue } from 'clsx'
import type { LocaleCode } from '@/lib/locales'
export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Date (d) : d
@@ -10,64 +15,119 @@ export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Da
export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
export const dateString = (
d: string | Date,
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null): string =>
toDate (d).toLocaleString (
'ja-JP-u-ca-japanese',
{ era: 'long',
year: 'numeric',
month: (unknown === 'month' ? undefined : 'long'),
type DatePrecision = 'month' | 'day' | 'hour' | 'minute' | 'second' | null
const dateFormatters = new Map<DatePrecision, Intl.DateTimeFormat> ()
let ainuDateFormatter: Intl.DateTimeFormat | null = null
const formatDateString = (
date: Date,
unknown: DatePrecision,
locale: LocaleCode,
): string => {
if (Number.isNaN (date.getTime ()))
return date.toString ()
if (locale === 'ja')
{
if (!dateFormatters.has (unknown))
{
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',
hour: unknown == null || ['second', 'minute'].includes (unknown) ? 'numeric' : undefined,
minute: unknown == null || unknown === 'second' ? 'numeric' : undefined,
second: unknown == null ? 'numeric' : undefined })
second: unknown == null ? 'numeric' : undefined }))
}
return dateFormatters.get (unknown)!.format (date)
}
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)
const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? ''
const omitDay = unknown != null && ['month', 'day'].includes (unknown)
const dateParts = [part ('year'),
...(unknown === 'month' ? [] : [part ('month')]),
...(omitDay ? [] : [part ('day')])]
const dateText = dateParts.join ('-')
if (unknown != null && ['month', 'day', 'hour'].includes (unknown))
return dateText
const timeParts = [part ('hour'),
...(unknown === 'minute' ? [] : [part ('minute')]),
...(unknown == null ? [part ('second')] : [])]
return `${dateText}\u3000${timeParts.join (':')}`
}
export const dateString = (
value: string | Date,
unknown: DatePrecision = null,
locale?: LocaleCode,
): string => formatDateString (toDate (value), unknown, locale ?? currentViewingLocale ())
export const localisedDateString = (
value: string | Date,
locale: LocaleCode,
unknown: DatePrecision = null,
): string => dateString (value, unknown, locale)
export const originalCreatedAtString = (
f: string | Date | null,
b: string | Date | null): string => {
b: string | Date | null,
locale: LocaleCode = 'ja',
): string => {
const from = f ? toDate (f) : null
const before = b ? toDate (b) : null
const format = (value: Date, unknown: Parameters<typeof dateString>[1]) =>
localisedDateString (value, locale, unknown)
const unknownLabel = (key: string) => i18n.t (`history:${key}`, { lng: locale })
const space = locale === 'ja' ? ' ' : '\u3000'
if (from && before)
{
const diff = before.getTime () - from.getTime ()
if (diff <= 60_000 /* 1 分 */)
return dateString (from, 'second')
return format (from, 'second')
if (from.getMinutes () === 0 && before.getMinutes () === 0)
{
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000)
return dateString (from, 'minute') + ' (分不詳)'
return `${format (from, 'minute')}${space}(${unknownLabel ('unknownMinute')})`
if (from.getHours () === 0 && before.getHours () === 0)
{
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
return dateString (from, 'hour') + ' (時刻不詳)'
return `${format (from, 'hour')}${space}(${unknownLabel ('unknownTime')})`
if (from.getDate () === 1 && before.getDate () === 1)
{
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
return dateString (from, 'day') + ' (日不詳)'
return `${format (from, 'day')}${space}(${unknownLabel ('unknownDay')})`
if (from.getMonth () === 0 && before.getMonth () === 0
&& (31_449_600_000 /* 364 日 */ <= diff
&& diff < 31_708_800_000 /* 367 日 */))
return dateString (from, 'month') + ' (月日不詳)'
return `${format (from, 'month')}${space}(${unknownLabel ('unknownMonthDay')})`
}
}
}
}
const rtn = ([from ? `${ dateString (from, 'second') }` : '',
const rtn = ([from ? format (from, 'second') : '',
'〜',
before ? `${ dateString (new Date (before.getTime () - 60_000), 'second') }` : '']
before ? format (new Date (before.getTime () - 60_000), 'second') : '']
.filter (Boolean)
.join (' '))
return rtn === '〜' ? '年月日不詳' : rtn
.join (space))
return rtn === '〜' ? unknownLabel ('unknownDate') : rtn
}
+74 -5
ファイルの表示
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki'
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages,
switchWikiLocale } from '@/lib/wiki'
const api = vi.hoisted (() => ({ apiGet: vi.fn () }))
@@ -8,7 +9,75 @@ vi.mock ('@/lib/api', () => api)
describe ('wiki API functions', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.apiGet.mockReset ()
})
it.each (['detail', 'edit', 'diff', 'history'] as const) (
'switches only the Locale for a missing resource on the %s screen',
async view => {
api.apiGet.mockRejectedValue ({
isAxiosError: true, response: { status: 404 } })
const pathname = view === 'history' ? '/wiki/changes'
: `/wiki/${encodeURIComponent ('げいかうい')}${
view === 'detail' ? '' : `/${view}`}`
const search = '?id=missing&from=11&to=12&version=3&page=2'
const params = view === 'history' ? { }
: (view === 'detail' ? { title: 'げいかうい' } : { id: 'げいかうい' })
const path = await switchWikiLocale ({
location: { pathname: `/ain-Kana${pathname}`, search, hash: '#section' },
params,
locale: 'ain-Kana', targetLocale: 'ja' }, view)
expect (path).toBe (`/ja${pathname}${search}#section`)
})
it.each (['wiki', 'tag'] as const) (
'rejects communication failures during the %s lookup', async lookup => {
const error = new Error ('network failure')
if (lookup === 'tag')
api.apiGet.mockRejectedValueOnce ({
isAxiosError: true, response: { status: 404 } })
api.apiGet.mockRejectedValueOnce (error)
await expect (switchWikiLocale ({
location: { pathname: '/ain-Kana/wiki/missing', search: '', hash: '' },
params: { title: 'missing' }, locale: 'ain-Kana', targetLocale: 'ja' }))
.rejects.toBe (error)
})
it.each (['edit', 'diff'] as const) ('keeps the %s screen with the target Wiki ID',
async view => {
api.apiGet.mockResolvedValueOnce ({ id: 101, tagId: 7, resourceType: 'tag_description' })
api.apiGet.mockResolvedValueOnce ({ id: 202, tagId: 7, resourceType: 'tag_description' })
const path = await switchWikiLocale ({
location: { pathname: `/ja/wiki/101/${view}`, search: '?from=11&to=12&keep=1',
hash: '#section' },
params: { id: '101' }, locale: 'ja', targetLocale: 'ain-Kana' }, view)
expect (path).toBe (`/ain-Kana/wiki/202/${view}?keep=1#section`)
expect (api.apiGet).toHaveBeenNthCalledWith (2, '/wiki/101', {
locale: 'ain-Kana', params: { } })
})
it ('keeps filtered history with the target-language description ID', async () => {
api.apiGet.mockResolvedValueOnce ({ id: 101 })
api.apiGet.mockResolvedValueOnce ({ id: 202 })
const path = await switchWikiLocale ({
location: { pathname: '/ja/wiki/changes', search: '?id=101&keep=1', hash: '' },
params: { }, locale: 'ja', targetLocale: 'ain-Kana' }, 'history')
expect (path).toBe ('/ain-Kana/wiki/changes?id=202&keep=1')
})
it ('keeps history and clears its ID filter when the translation is missing', async () => {
api.apiGet.mockResolvedValueOnce ({ id: 101, resourceType: 'tag_description' })
api.apiGet.mockRejectedValueOnce ({
isAxiosError: true, response: { status: 404 } })
api.apiGet.mockResolvedValueOnce ({ id: 7, name: 'シロシ' })
api.apiGet.mockRejectedValueOnce ({
isAxiosError: true, response: { status: 404 } })
api.apiGet.mockResolvedValueOnce ({ id: 7, name: 'シロシ' })
const path = await switchWikiLocale ({
location: { pathname: '/ja/wiki/changes',
search: '?id=101&page=2&keep=1', hash: '#history' },
params: { }, locale: 'ja', targetLocale: 'ain-Kana' }, 'history')
expect (path).toBe ('/ain-Kana/wiki/changes?page=2&keep=1#history')
})
it ('fetches wiki index and show pages with expected parameters', async () => {
@@ -21,11 +90,11 @@ describe ('wiki API functions', () => {
expect (api.apiGet).toHaveBeenNthCalledWith (
1,
'/wiki',
{ params: { title: '虹' } })
{ locale: 'ja', params: { title: '虹' } })
expect (api.apiGet).toHaveBeenNthCalledWith (
2,
'/wiki/1',
{ params: { version: '3' } })
{ locale: 'ja', params: { version: '3' } })
})
it ('encodes title path segments and returns null on misses', async () => {
@@ -38,7 +107,7 @@ describe ('wiki API functions', () => {
expect (api.apiGet).toHaveBeenNthCalledWith (
1,
'/wiki/title/a%2Fb%20c',
{ params: { version: undefined } })
{ locale: 'ja', params: { version: undefined } })
})
it.each ([403, 500]) ('preserves HTTP %s errors', async status => {
+112 -7
ファイルの表示
@@ -1,27 +1,52 @@
import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api'
import { switchLocalePath } from '@/lib/locales'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import type { WikiPage } from '@/types'
import type { LocaleCode } from '@/lib/locales'
import type { LocaleSwitchRequest } from '@/lib/routeLocales'
import type { Tag, WikiAutolinkCandidate, WikiPage,
WikiPageChange, WikiTagReference } from '@/types'
export const fetchWikiAutolinkCandidates = (
language: string,
locale: LocaleCode,
): Promise<WikiAutolinkCandidate[]> =>
apiGet ('/wiki/autolink_candidates', { locale, params: { language } })
export const fetchWikiChanges = (
params: { id?: string },
locale: LocaleCode = 'ja',
): Promise<WikiPageChange[]> => apiGet ('/wiki/changes', { locale, params })
export const fetchWikiPages = async (
{ title }: { title?: string }): Promise<WikiPage[]> =>
await apiGet ('/wiki', { params: { title } })
{ title }: { title?: string },
locale: LocaleCode = 'ja',
): Promise<WikiPage[]> =>
await apiGet ('/wiki', { locale, params: { title } })
export const fetchWikiPage = async (
id: string,
{ version }: { version?: string }): Promise<WikiPage> =>
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } })
{ version }: { version?: string },
locale: LocaleCode = 'ja',
): Promise<WikiPage> =>
await apiGet (`/wiki/${ encodeURIComponent (id) }`, {
locale, params: version ? { version } : { } })
export const fetchWikiPageByTitle = async (
title: string,
{ version }: { version?: string }): Promise<WikiPage | null> => {
{ version }: { version?: string },
locale: LocaleCode = 'ja',
): Promise<WikiPage | null> => {
try
{
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { locale, params: { version } })
}
catch (error)
{
@@ -30,3 +55,83 @@ export const fetchWikiPageByTitle = async (
throw error
}
}
export const fetchWikiPageByReference = async (
reference: string,
params: { version?: string },
locale: LocaleCode,
): Promise<WikiPage | null> => {
try
{
return /^\d+$/.test (reference)
? await fetchWikiPage (reference, params, locale)
: await fetchWikiPageByTitle (reference, params, locale)
}
catch (error)
{
if (!isAxiosError (error) || error.response?.status !== 404)
throw error
const tag = await fetchWikiTag ({ wikiId: reference }, locale)
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
}
}
export const fetchWikiTag = async (
reference: WikiTagReference,
locale: LocaleCode,
): Promise<Tag | null> => {
if ('name' in reference)
return fetchTagByName (reference.name, locale)
try
{
return await apiGet (`/wiki/${encodeURIComponent (reference.wikiId)}/tag`, { locale })
}
catch (error)
{
if (!isAxiosError (error) || error.response?.status !== 404)
throw error
return fetchTagByName (reference.wikiId, locale)
}
}
export const switchWikiLocale = async (
{ location, params, locale, targetLocale }: LocaleSwitchRequest,
view: 'detail' | 'edit' | 'diff' | 'history' = 'detail',
): Promise<string> => {
const query = new URLSearchParams (location.search)
const reference = params.title ?? params.id ?? query.get ('id')
if (reference == null)
return switchLocalePath (location, targetLocale)
const page = await fetchWikiPageByReference (reference, { }, locale)
const sourceTag = page == null ? await fetchWikiTag (
/^\d+$/.test (reference) ? { wikiId: reference } : { name: reference }, locale) : null
if (page == null && sourceTag == null)
return switchLocalePath (location, targetLocale)
const translated = page == null ? null
: await fetchWikiPageByReference (String (page.id), { }, targetLocale)
const tag = sourceTag != null
? await fetchTag (String (sourceTag.id), targetLocale)
: ((translated == null && page?.resourceType === 'tag_description')
? await fetchWikiTag ({ wikiId: String (page.id) }, targetLocale) : null)
const destination = tag?.name ?? translated?.id ?? page?.id
if (destination == null)
throw new Error ('Wiki resource could not be resolved')
for (const key of ['version', 'from', 'to'])
query.delete (key)
const historyId = view === 'history' ? translated?.id : null
if (historyId != null)
query.set ('id', String (historyId))
else
query.delete ('id')
const suffix = (translated != null && (view === 'edit' || view === 'diff'))
? `/${view}` : ''
return switchLocalePath ({
pathname: view === 'history'
? location.pathname
: `/wiki/${encodeURIComponent (destination)}${suffix}`,
search: query.size === 0 ? '' : `?${query}`,
hash: location.hash }, targetLocale)
}