Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' }] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
|
||||
import type { RenderContext } from '@/lib/renderContext'
|
||||
|
||||
const requests = new AsyncLocalStorage<RenderContext> ()
|
||||
|
||||
|
||||
export const currentRenderContext = (): RenderContext | undefined => requests.getStore ()
|
||||
|
||||
|
||||
export const withRenderContext = <T,> (context: RenderContext, render: () => T): T =>
|
||||
requests.run (context, render)
|
||||
@@ -0,0 +1,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 }
|
||||
}
|
||||
@@ -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[] }))
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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, '&').replace (/</g, '<').replace (/>/g, '>')
|
||||
.replace (/"/g, '"').replace (/'/g, ''')
|
||||
|
||||
|
||||
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)]) }
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 ())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
const subscribe = () => () => { }
|
||||
const clientSnapshot = () => true
|
||||
const serverSnapshot = () => false
|
||||
|
||||
|
||||
export const useHydrated = (): boolean =>
|
||||
useSyncExternalStore (subscribe, clientSnapshot, serverSnapshot)
|
||||
@@ -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],
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする