From bbee83e35b6948450f0c3e45d574e19df8ac5a06 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 18 Oct 2026 13:56:03 +0900 Subject: [PATCH] #428 --- .../app/controllers/wiki_pages_controller.rb | 28 +++ backend/app/representations/wiki_page_repr.rb | 22 ++ backend/config/routes.rb | 1 + frontend/src/components/LocalisedText.tsx | 46 ++++- .../PostOriginalCreatedTimeField.tsx | 108 +++++----- frontend/src/components/WikiBody.tsx | 33 ++- frontend/src/index.css | 25 +++ frontend/src/lib/prefetchers.ts | 28 +-- frontend/src/lib/queryKeys.ts | 2 + frontend/src/lib/remark-wiki-autolink.ts | 195 ++++++++++-------- frontend/src/lib/wiki.ts | 32 ++- frontend/src/locales/ain-Kana/posts.json | 6 +- frontend/src/mdx-components.tsx | 60 +++++- frontend/src/pages/TOSPage/ain-Kana.mdx | 2 +- frontend/src/pages/TOSPage/index.tsx | 11 +- frontend/src/pages/help/HelpDetailPage.tsx | 17 +- .../pages/materials/MaterialDetailPage.tsx | 2 + frontend/src/pages/posts/PostListPage.tsx | 9 +- frontend/src/pages/tags/TagDetailPage.tsx | 3 +- frontend/src/pages/wiki/WikiDetailPage.tsx | 61 +++--- frontend/src/types.ts | 13 ++ 21 files changed, 468 insertions(+), 236 deletions(-) diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 9db7e64..34b0ae6 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -34,6 +34,34 @@ class WikiPagesController < ApplicationController render json: end + def autolink_candidates + locale = resolve_locale! + language = params.fetch(:language, locale.language_code) + return head :not_found unless language.is_a?(String) + target_locale = + if language == 'und' + locale + else + Locale.where(language_code: language, deprecated_at: nil, + code: Locale.codes.values).order(:code).first + end + return head :not_found unless target_locale + + candidates = [] + unless language == 'und' + names = TagDescription.joins(tag: :tag_names) + .where(language_code: language, tag_names: { language_code: language }) + .pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name') + candidates = names.map do |tag_id, description_id, name| + WikiPageRepr.autolink_tag(target_locale, tag_id:, description_id:, name:) + end + end + candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name| + WikiPageRepr.autolink_group(target_locale, id:, name:, language:) + end) + render json: candidates + end + def show locale = resolve_locale! tag_description = TagDescription.find_by(id: params[:id], language_code: locale.language_code) diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index 74324d8..b6e3e41 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -9,6 +9,8 @@ module WikiPageRepr { 'id' => description.id, 'resource_type' => 'tag_description', + 'tag_id' => tag.id, + 'language_code' => description.language_code, 'tag_name_id' => tag.tag_name(locale.language_code)&.id, 'body' => description.body, 'created_user_id' => description.created_by_user_id, @@ -24,4 +26,24 @@ module WikiPageRepr def tag_group tag_group TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group') end + + def autolink_tag locale, tag_id:, description_id:, name: + { resource_type: 'tag_description', + resource_id: description_id, + tag_id:, + tag_description_id: description_id, + name:, + language_code: locale.language_code, + locale_code: locale.code_before_type_cast } + end + + def autolink_group locale, id:, name:, language: + { resource_type: 'tag_group', + resource_id: id, + tag_id: nil, + tag_description_id: nil, + name: "#{ TagGroupRepr::PREFIX }#{ name }", + language_code: language, + locale_code: locale.code_before_type_cast } + end end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 8619040..9752bde 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -45,6 +45,7 @@ Rails.application.routes.draw do collection do get :search, action: :index get :changes + get :autolink_candidates scope :title do get ':title/exists', action: :exists_by_title diff --git a/frontend/src/components/LocalisedText.tsx b/frontend/src/components/LocalisedText.tsx index 5ad3909..b8c76d3 100644 --- a/frontend/src/components/LocalisedText.tsx +++ b/frontend/src/components/LocalisedText.tsx @@ -11,6 +11,29 @@ export const formatUIString = (text: string, language: string): string => : text +const splitAinuText = (text: string): string[] => { + const parts: string[] = [] + let part = '' + let boundary = false + for (const character of text) + { + // Keep punctuation runs and closing brackets/quotes with the preceding word. + if (boundary && !/[\u3000、,,。.\p{Pe}\p{Pf}]/u.test (character)) + { + parts.push (part) + part = '' + boundary = false + } + part += character + if (/[\u3000、,,。.]/u.test (character)) + boundary = true + } + if (part !== '') + parts.push (part) + return parts +} + + export const localiseUIChildren = (children: ReactNode, language: string): ReactNode => { if (language !== 'ain' && language !== 'ain-Kana') return children @@ -18,15 +41,28 @@ export const localiseUIChildren = (children: ReactNode, language: string): React return Children.map (children, child => { if (typeof child === 'string') { - return formatUIString (child, language).split (/(\u3000+)/).map ((part, index) => - part.includes ('\u3000') - ? {part} - : {part}) + const parts = splitAinuText (formatUIString (child, language)) + return parts.map ((part, index) => { + const spaces = part.match (/\u3000+$/u)?.[0] ?? '' + const word = part.slice (0, part.length - spaces.length) + return ( + + {word !== '' && {word}} + {spaces} + {index < parts.length - 1 && } + ) + }) } if (isValidElement<{ children?: ReactNode; lang?: string }> (child)) { - if (child.type === 'code' || child.type === 'pre' + // Only traverse inline formatting owned by the renderer. Components, + // links and code retain their own children and behaviour. + const inline = child.type === Fragment + || (typeof child.type === 'string' + && ['span', 'em', 'strong', 'del', 's', 'b', 'i', 'u', + 'small', 'mark', 'sup', 'sub'].includes (child.type)) + if (!inline || (child.props.lang != null && !(child.props.lang.startsWith ('ain')))) return child return cloneElement (child, { }, localiseUIChildren (child.props.children, language)) diff --git a/frontend/src/components/PostOriginalCreatedTimeField.tsx b/frontend/src/components/PostOriginalCreatedTimeField.tsx index a8b485b..9ed0c77 100644 --- a/frontend/src/components/PostOriginalCreatedTimeField.tsx +++ b/frontend/src/components/PostOriginalCreatedTimeField.tsx @@ -27,66 +27,64 @@ const PostOriginalCreatedTimeField: FC = ( {({ describedBy, invalid }) => ( <> -
-
- { - const v = ev.target.value - if (!(v)) - return +
+ { + const v = ev.target.value + if (!(v)) + return - const d = new Date (v) - if (d.getMinutes () === 0 && d.getHours () === 0) - d.setDate (d.getDate () + 1) - else - d.setMinutes (d.getMinutes () + 1) - setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ())) - }}/> + const d = new Date (v) + if (d.getMinutes () === 0 && d.getHours () === 0) + d.setDate (d.getDate () + 1) + else + d.setMinutes (d.getMinutes () + 1) + setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ())) + }}/> + {label ('labels.since')} -
-
- -
+ +
-
-
- +
+ + {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')}
)} -
- {Content ? :
{label ('common:loading')}
} +
+ {Content + ? ( + + + ) + :
{label ('common:loading')}
}
) } diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index ae99f90..0f62b28 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -19,6 +19,7 @@ import { updateMaterial, } from '@/lib/materials' import { materialsKeys } from '@/lib/queryKeys' +import { localeLanguage } from '@/lib/locales' import { inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import { useUITranslation } from '@/lib/useUITranslation' @@ -132,6 +133,7 @@ const MaterialDetailPage: FC = () => { )} diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 602f801..cb2942e 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -12,6 +12,7 @@ import TabGroup, { Tab } from '@/components/common/TabGroup' import MainArea from '@/components/layout/MainArea' import PageHead from '@/components/common/PageHead' import { fetchPosts } from '@/lib/posts' +import { localeLanguage } from '@/lib/locales' import { postsKeys } from '@/lib/queryKeys' import { DEFAULT_POST_LIST_LIMIT, @@ -130,8 +131,8 @@ const PostListPage: FC = () => { ref={containerRef}> 0}/> { @@ -173,7 +174,9 @@ const PostListPage: FC = () => { {tags.length === 1 && ( - +
{label ('list.viewWiki')} diff --git a/frontend/src/pages/tags/TagDetailPage.tsx b/frontend/src/pages/tags/TagDetailPage.tsx index 1fd1bcb..c0e9314 100644 --- a/frontend/src/pages/tags/TagDetailPage.tsx +++ b/frontend/src/pages/tags/TagDetailPage.tsx @@ -12,7 +12,7 @@ import PageHead from '@/components/common/PageHead' import { toast } from '@/components/ui/use-toast' import { CATEGORIES } from '@/consts' import { apiPut } from '@/lib/api' -import { postsKeys, tagsKeys } from '@/lib/queryKeys' +import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { fetchTag } from '@/lib/tags' import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' @@ -75,6 +75,7 @@ const TagDetailPage: FC = () => { qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) + qc.invalidateQueries ({ queryKey: wikiKeys.root }) toast ({ description: label ('shared:messages.updated') }) } catch (e) diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index 8882e3c..bdb381d 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -13,10 +13,11 @@ import MainArea from '@/components/layout/MainArea' import PageHead from '@/components/common/PageHead' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { getHelpTitleIndex } from '@/lib/help' +import { localeLanguage } from '@/lib/locales' import { fetchPosts } from '@/lib/posts' import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { fetchTagByName } from '@/lib/tags' -import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki' +import { fetchWikiPageByReference } from '@/lib/wiki' import NotFound from '@/pages/NotFound' import type { FC } from 'react' @@ -29,6 +30,7 @@ const WikiDetailPage: FC = () => { const locale = useLocale () const params = useParams () const title = params.title ?? '' + const numericReference = /^\d+$/.test (title) const location = useLocation () const navigate = useLocaleNavigate () @@ -39,14 +41,14 @@ const WikiDetailPage: FC = () => { const version = query.get ('version') || undefined const { data: wikiPage, isLoading: loading, isSuccess, isFetching, error } = useQuery ({ - enabled: Boolean (title) && !(/^\d+$/.test (title)), + enabled: Boolean (title), queryKey: wikiKeys.show (title, { version }, locale), - queryFn: () => fetchWikiPageByTitle (title, { version }, locale) }) + queryFn: () => fetchWikiPageByReference (title, { version }, locale) }) const missingWiki = isSuccess && !(isFetching) && wikiPage == null const { data: titleIndex, isPending: lookupPending, isFetching: lookupFetching, error: lookupError } = useQuery ({ - enabled: missingWiki, + enabled: missingWiki && !numericReference, queryKey: helpKeys.titles ('ja'), queryFn: () => getHelpTitleIndex ('ja'), staleTime: Infinity }) @@ -56,16 +58,17 @@ const WikiDetailPage: FC = () => { ? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle]) useEffect (() => { - if (missingWiki && !(lookupFetching) && lookupError == null && identifiers.length === 1) + if (missingWiki && !numericReference + && !(lookupFetching) && lookupError == null && identifiers.length === 1) navigate (`/help/${ encodeURIComponent (identifiers[0]) }`, { replace: true }) - }, [missingWiki, lookupFetching, lookupError, identifiers, navigate]) + }, [missingWiki, numericReference, lookupFetching, lookupError, identifiers, navigate]) const effectiveTitle = wikiPage?.title ?? title const deprecated = wikiPage?.deprecatedAt != null const { data: tag, isPending: tagPending, isFetching: tagFetching, error: tagError } = useQuery ({ - enabled: Boolean (effectiveTitle), + enabled: Boolean (effectiveTitle) && (!numericReference || wikiPage != null), queryKey: tagsKeys.show (effectiveTitle, locale), queryFn: () => fetchTagByName (effectiveTitle, locale) }) @@ -75,7 +78,8 @@ const WikiDetailPage: FC = () => { createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', order: 'original_created_at:desc' } as const const { data } = useQuery ({ - enabled: Boolean (effectiveTitle) && !(version), + enabled: Boolean (effectiveTitle) && !(version) + && (!numericReference || wikiPage != null), queryKey: postsKeys.index (keys, locale), queryFn: () => fetchPosts (keys, locale) }) const posts = data?.posts || [] @@ -86,39 +90,22 @@ const WikiDetailPage: FC = () => { WikiIdBus.set (wikiPage.id) - if (wikiPage.title !== title) - navigate (`/wiki/${ encodeURIComponent(wikiPage.title) }`, { replace: true }) + if (!numericReference && wikiPage.title !== title) + { + navigate ({ pathname: `/wiki/${ encodeURIComponent (wikiPage.title) }`, + search: location.search, hash: location.hash }, { replace: true }) + } return () => WikiIdBus.set (null) - }, [wikiPage, title, navigate]) - - useEffect (() => { - if (!(/^\d+$/.test (title))) - return - - let active = true - void (async () => { - try - { - const data = await fetchWikiPage (title, { }, locale) - if (active) - navigate (`/wiki/${ encodeURIComponent (data.title) }`, { replace: true }) - } - catch - { - ; - } - }) () - - return () => { - active = false - } - }, [title, navigate, locale]) + }, [wikiPage, title, numericReference, navigate, location.search, location.hash]) const displayError = error ?? (missingWiki ? lookupError ?? tagError : null) if (displayError != null) return

{displayError.message}

+ if (missingWiki && numericReference) + return + const waiting = loading || (isFetching && wikiPage == null) || (missingWiki && (lookupPending || lookupFetching || tagPending || (tagFetching && tag == null) || identifiers.length === 1)) @@ -161,7 +148,11 @@ const WikiDetailPage: FC = () => { {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> {deprecated && label ('labels.deprecatedSuffix')} - {waiting ? label ('common:loading') : } + {waiting + ? label ('common:loading') + : } {(!(version) && posts.length > 0) && (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 17c9b71..619f568 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -6,6 +6,8 @@ import { CATEGORIES, import type { ReactNode } from 'react' +import type { LocaleCode } from '@/lib/locales' + export type Category = typeof CATEGORIES[number] export type Deerjikist = { platform: Platform; code: string } @@ -459,6 +461,8 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha export type WikiPage = { id: number resourceType: 'tag_description' | 'tag_group' + tagId?: number + languageCode?: string title: string deprecatedAt: string | null createdUserId: number @@ -470,6 +474,15 @@ export type WikiPage = { pred: number | null succ: number | null } +export type WikiAutolinkCandidate = { + resourceType: 'tag_description' | 'tag_group' + resourceId: number + tagId: number | null + tagDescriptionId: number | null + name: string + languageCode: string + localeCode: LocaleCode } + export type WikiPageChange = { revisionId: number pred: number | null