-
-
+
+
+
{label ('labels.before')}
-
-
-
-
+
+
>)}
)
diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx
index dd53b9a..1a41e9f 100644
--- a/frontend/src/components/WikiBody.tsx
+++ b/frontend/src/components/WikiBody.tsx
@@ -8,13 +8,15 @@ import { useUITranslation } from '@/lib/useUITranslation'
import PrefetchLink from '@/components/PrefetchLink'
import { wikiKeys } from '@/lib/queryKeys'
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
-import { fetchWikiPages } from '@/lib/wiki'
+import { fetchWikiAutolinkCandidates } from '@/lib/wiki'
+import { ainuMDXComponents, LocalisedDocument } from '@/mdx-components'
import type { FC } from 'react'
import type { Components } from 'react-markdown'
type Props = { title: string
- body?: string }
+ body?: string
+ language?: string }
const mdComponents = { a: (({ href, children }) => (
['/', '.'].some (e => href?.startsWith (e))
@@ -27,23 +29,32 @@ const mdComponents = { a: (({ href, children }) => (
))) } as const satisfies Components
-const WikiBody: FC
= ({ title, body }) => {
+const WikiBody: FC = ({ title, body, language = 'und' }) => {
const { message } = useUITranslation ('wiki')
const locale = useLocale ()
+ const missingPage = message ('missingPage', {
+ url: `/wiki/new?title=${ encodeURIComponent (title) }` })
+ const contentLanguage = body ? language : missingPage.language
+ const matchLanguage = contentLanguage === 'ain-Kana' ? 'ain' : contentLanguage
const { data } = useQuery ({
enabled: Boolean (body),
- queryKey: wikiKeys.index ({ }, locale),
- queryFn: () => fetchWikiPages ({ }, locale) })
- const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length)
+ queryKey: wikiKeys.autolinkCandidates (matchLanguage, locale),
+ queryFn: () => fetchWikiAutolinkCandidates (matchLanguage, locale) })
const remarkPlugins = useMemo (
- () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames])
+ () => [() => remarkWikiAutoLink (data ?? [], matchLanguage), remarkGFM],
+ [data, matchLanguage])
+ const components = {
+ ...((contentLanguage === 'ain' || contentLanguage === 'ain-Kana')
+ ? ainuMDXComponents : { }),
+ ...mdComponents }
return (
-
- {body || message ('missingPage', {
- url: `/wiki/new?title=${ encodeURIComponent (title) }` }).text}
- )
+
+
+ {body || missingPage.text}
+
+ )
}
export default WikiBody
diff --git a/frontend/src/index.css b/frontend/src/index.css
index c2e8735..09ad7cb 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -13,11 +13,36 @@
.ainu-ui-word
{
+ display: inline-block;
white-space: nowrap !important;
word-break: keep-all !important;
overflow-wrap: normal !important;
}
+.ainu-ui-label .ainu-ui-text
+{
+ white-space: nowrap !important;
+}
+
+.ainu-ui-label wbr
+{
+ display: none;
+}
+
+.localised-document
+{
+ min-width: 0;
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+.localised-document a:lang(ain)
+{
+ white-space: nowrap;
+ word-break: keep-all;
+ overflow-wrap: normal;
+}
+
@layer base
{
:root
diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts
index 64827ce..64d8240 100644
--- a/frontend/src/lib/prefetchers.ts
+++ b/frontend/src/lib/prefetchers.ts
@@ -11,8 +11,8 @@ 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 { fetchWikiAutolinkCandidates,
+ fetchWikiPageByReference,
fetchWikiPages } from '@/lib/wiki'
import type { LocaleCode } from '@/lib/locales'
@@ -114,22 +114,16 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => {
const wikiPage = await qc.fetchQuery ({
queryKey: wikiKeys.show (title, { version }, locale),
- queryFn: () => fetchWikiPageByTitle (title, { version }, locale) })
+ queryFn: () => fetchWikiPageByReference (title, { version }, locale) })
- if (wikiPage)
- {
- const effectiveId = String (wikiPage.id ?? '')
- await qc.prefetchQuery ({
- queryKey: wikiKeys.show (effectiveId, { }, locale),
- queryFn: () => fetchWikiPage (effectiveId, { }, locale ) })
-
- if (wikiPage.body)
- {
- await qc.prefetchQuery ({
- queryKey: wikiKeys.index ({ }, locale),
- queryFn: () => fetchWikiPages ({ }, locale) })
- }
- }
+ if (wikiPage?.body)
+ {
+ const language = wikiPage.languageCode
+ ?? (wikiPage.resourceType === 'tag_description' ? localeLanguage (locale) : 'und')
+ await qc.prefetchQuery ({
+ queryKey: wikiKeys.autolinkCandidates (language, locale),
+ queryFn: () => fetchWikiAutolinkCandidates (language, locale) })
+ }
const effectiveTitle = wikiPage?.title ?? title
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index 6da28ee..bc8a713 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -123,6 +123,8 @@ export const materialsKeys = {
export const wikiKeys = {
root: ['wiki'] as const,
+ autolinkCandidates: (language: string, locale: LocaleCode) =>
+ ['wiki', 'autolink', locale, language] as const,
index: (
p: { title?: string },
locale: LocaleCode = 'ja',
diff --git a/frontend/src/lib/remark-wiki-autolink.ts b/frontend/src/lib/remark-wiki-autolink.ts
index e7ec8f0..7e9e606 100644
--- a/frontend/src/lib/remark-wiki-autolink.ts
+++ b/frontend/src/lib/remark-wiki-autolink.ts
@@ -1,101 +1,128 @@
import type { Content, Parent, Root, RootContent } from 'mdast'
+import type { WikiAutolinkCandidate } from '@/types'
+
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,
+): ((tree: Root) => void) => {
+ const targets = new Map ()
+ const ambiguous = new Set ()
+ for (const candidate of candidates)
+ {
+ if (candidate.languageCode !== language || 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: `/${candidate.localeCode}/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
diff --git a/frontend/src/lib/wiki.ts b/frontend/src/lib/wiki.ts
index 015e8f8..5d695eb 100644
--- a/frontend/src/lib/wiki.ts
+++ b/frontend/src/lib/wiki.ts
@@ -3,7 +3,14 @@ import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
-import type { WikiPage, WikiPageChange } from '@/types'
+import type { WikiAutolinkCandidate, WikiPage, WikiPageChange } from '@/types'
+
+
+export const fetchWikiAutolinkCandidates = (
+ language: string,
+ locale: LocaleCode,
+): Promise =>
+ apiGet ('/wiki/autolink_candidates', { locale, params: { language } })
export const fetchWikiChanges = (
@@ -24,7 +31,8 @@ export const fetchWikiPage = async (
{ version }: { version?: string },
locale: LocaleCode = 'ja',
): Promise =>
- await apiGet (`/wiki/${ id }`, { locale, params: version ? { version } : { } })
+ await apiGet (`/wiki/${ encodeURIComponent (id) }`, {
+ locale, params: version ? { version } : { } })
export const fetchWikiPageByTitle = async (
@@ -43,3 +51,23 @@ export const fetchWikiPageByTitle = async (
throw error
}
}
+
+
+export const fetchWikiPageByReference = async (
+ reference: string,
+ params: { version?: string },
+ locale: LocaleCode,
+): Promise => {
+ try
+ {
+ return /^\d+$/.test (reference)
+ ? await fetchWikiPage (reference, params, locale)
+ : await fetchWikiPageByTitle (reference, params, locale)
+ }
+ catch (error)
+ {
+ if (isAxiosError (error) && error.response?.status === 404)
+ return null
+ throw error
+ }
+}
diff --git a/frontend/src/locales/ain-Kana/posts.json b/frontend/src/locales/ain-Kana/posts.json
index 812d3bc..0310a6b 100644
--- a/frontend/src/locales/ain-Kana/posts.json
+++ b/frontend/src/locales/ain-Kana/posts.json
@@ -1,7 +1,7 @@
{
"list": {
- "tagline": "{{brand}} | ぼざろクリーチャーシリーズ ウコテㇷ゚ オピッタ",
- "empty": "ミンタㇻ タ ネン カ イサㇺ(笑)",
+ "tagline": "{{brand}}",
+ "empty": "ミンタㇻ タ ネン カ イサㇺ",
"viewWiki": "Wiki ヌカㇻ",
"and": " AND ",
"or": " OR "
@@ -20,7 +20,7 @@
"parentAndSiblings": "タン ミンタㇻ コㇿ サパ ミンタㇻ シネ ペ ネワ ウコシㇼキ ミンタㇻ {{count}} ペ アン。",
"viewed": "アヌカㇻ オケレ",
"unviewed": "ソモ アヌカㇻ",
- "emptyRelated": "ネン カ イサㇺ(笑)"
+ "emptyRelated": "ネン カ イサㇺ"
},
"embed": {
"openExternal": "ソイタㇰ ソㇱ アㇱマカ",
diff --git a/frontend/src/mdx-components.tsx b/frontend/src/mdx-components.tsx
index 552c11c..735fd8d 100644
--- a/frontend/src/mdx-components.tsx
+++ b/frontend/src/mdx-components.tsx
@@ -1,4 +1,4 @@
-import { createElement, useMemo } from 'react'
+import { createContext, createElement, useContext, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
@@ -6,7 +6,24 @@ import { localiseUIChildren } from '@/components/LocalisedText'
import { cn } from '@/lib/utils'
import type { MDXComponents } from 'mdx/types'
-import type { ComponentProps, HTMLAttributes } from 'react'
+import type { ComponentProps, HTMLAttributes, ReactNode } from 'react'
+
+
+const DocumentLanguageContext = createContext ('ja')
+
+
+export const LocalisedDocument = (
+ { language, children, className }: {
+ language: string
+ children: ReactNode
+ className?: string },
+) => (
+
+
+ {children}
+
+ )
const MDXLink = (
@@ -51,20 +68,40 @@ const MDXLink = (
}
-export const useMDXComponents = (): MDXComponents => ({ a: MDXLink })
+export const useMDXComponents = (): MDXComponents => {
+ const language = useContext (DocumentLanguageContext)
+ return {
+ a: MDXLink,
+ ...((language === 'ain' || language === 'ain-Kana') ? ainuMDXComponents : { }) }
+}
const ainuElement = (element: string) => {
- const AinuElement = ({ children, className, ...props }: HTMLAttributes) =>
- createElement (element,
- { ...props, lang: 'ain-Kana', className: cn ('ainu-ui-text', className) },
- localiseUIChildren (children, 'ain-Kana'))
+ const AinuElement = (
+ { children, className, ...props }: HTMLAttributes & {
+ node?: unknown },
+ ) => {
+ const documentLanguage = useContext (DocumentLanguageContext)
+ const language = props.lang ?? documentLanguage
+ // ReactMarkdown supplies its syntax node; it is not a DOM attribute.
+ delete props.node
+ return (
+
+ {createElement (element,
+ { ...props,
+ lang: language === 'ain' ? 'ain-Kana' : language,
+ className: cn (
+ (language === 'ain' || language === 'ain-Kana') && 'ainu-ui-text',
+ className) },
+ localiseUIChildren (children, language))}
+ )
+ }
return AinuElement
}
-// Opt in only for authored Ainu UI documents, never user-supplied Markdown.
-export const ainuMDXComponents: MDXComponents = {
+// Opt in using the document's language, independently of the URL locale.
+export const ainuMDXComponents = {
p: ainuElement ('p'),
h1: ainuElement ('h1'),
h2: ainuElement ('h2'),
@@ -76,4 +113,7 @@ export const ainuMDXComponents: MDXComponents = {
dt: ainuElement ('dt'),
dd: ainuElement ('dd'),
th: ainuElement ('th'),
- td: ainuElement ('td') }
+ td: ainuElement ('td'),
+ em: ainuElement ('em'),
+ strong: ainuElement ('strong'),
+ del: ainuElement ('del') }
diff --git a/frontend/src/pages/TOSPage/ain-Kana.mdx b/frontend/src/pages/TOSPage/ain-Kana.mdx
index 1dc3dab..91cd57f 100644
--- a/frontend/src/pages/TOSPage/ain-Kana.mdx
+++ b/frontend/src/pages/TOSPage/ain-Kana.mdx
@@ -7,7 +7,7 @@ export const lastUpdatedAt = localisedDateString ('2026-04-12', 'ain-Kana', 'hou
# アエイワンケ イタㇰ
- イオッタ アシㇼ アヌイェ ト: {lastUpdatedAt}
+ イオッタ アシㇼ アヌイェ ト: {lastUpdatedAt}
タン アエイワンケ イタㇰ(タプ ワ 「タン イタㇰ」 セコㇿ アニェ) アナㇰネ、ポチ チャ ロㇰ シクヌㇷ゚ イキㇼ チセ(タプ ワ 「タン チヘコテㇷ゚」 セコㇿ アニェ) アエイワンケ ヒ オㇿ アヌイェ イタㇰ ネ。 タン チヘコテㇷ゚ エイワンケ クㇽ アナㇰネ、エイワンケ ヒ タ タン イタㇰ エセ ルウェ ネ セコㇿ インカルㇱクㇽ ラム。
diff --git a/frontend/src/pages/TOSPage/index.tsx b/frontend/src/pages/TOSPage/index.tsx
index 79ca327..8962aee 100644
--- a/frontend/src/pages/TOSPage/index.tsx
+++ b/frontend/src/pages/TOSPage/index.tsx
@@ -1,9 +1,9 @@
import PageHead from '@/components/common/PageHead'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
-import { ainuMDXComponents } from '@/mdx-components'
-import AinuTerms from '@/pages/TOSPage/ain-Kana.mdx'
-import NipponeseTerms from '@/pages/TOSPage/ja.mdx'
+import { LocalisedDocument } from '@/mdx-components'
+
+import type { ComponentType } from 'react'
const terms = import.meta.glob ('./*.mdx', { eager: true, import: 'default' })
@@ -12,6 +12,7 @@ const TOSPage = () => {
const locale = useLocale ()
const { message } = useUITranslation ('navigation')
const Terms = terms[`./${ locale }.mdx`] ?? terms['./ja.mdx']
+ const language = terms[`./${ locale }.mdx`] != null ? locale : 'ja'
if (!terms)
throw new Error ('TOSPage: ja.mdx not found')
@@ -19,7 +20,9 @@ const TOSPage = () => {
return (
<>
-
+
+
+
>)
}
diff --git a/frontend/src/pages/help/HelpDetailPage.tsx b/frontend/src/pages/help/HelpDetailPage.tsx
index 23ed90e..4198a29 100644
--- a/frontend/src/pages/help/HelpDetailPage.tsx
+++ b/frontend/src/pages/help/HelpDetailPage.tsx
@@ -14,6 +14,7 @@ import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { dateString } from '@/lib/utils'
import { helpKeys } from '@/lib/queryKeys'
+import { LocalisedDocument } from '@/mdx-components'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
@@ -53,8 +54,8 @@ const HelpDetailPage: FC = () => {
const { data: current, isSuccess, isError } = useQuery ({
enabled: edition != null && editionInfo != null && editions.includes (edition),
- queryKey: helpKeys.edition (identifier, requestedLanguage, edition ?? ''),
- queryFn: () => getHelpEditionContent (identifier, requestedLanguage, edition ?? ''),
+ queryKey: helpKeys.edition (identifier, language ?? requestedLanguage, edition ?? ''),
+ queryFn: () => getHelpEditionContent (identifier, language ?? requestedLanguage, edition ?? ''),
staleTime: Infinity })
if (!articleLoading
@@ -71,7 +72,7 @@ const HelpDetailPage: FC = () => {
const Content = current?.content
return (
-
+
{requestedEdition && (
@@ -99,8 +100,14 @@ const HelpDetailPage: FC = () => {
: label ('shared:actions.latest')}