From 74141f2a8480adafd793ec5be7bc4a1f56130aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Tue, 13 Jan 2026 19:51:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Wiki=20=E8=87=AA=E5=8B=95=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=AF=E3=82=92=E3=83=86=E3=82=AD=E3=82=B9=E3=83=88?= =?UTF-8?q?=E9=A0=98=E5=9F=9F=E3=81=AE=E3=81=BF=E3=81=AB=E5=88=B6=E9=99=90?= =?UTF-8?q?=EF=BC=88#93=EF=BC=89=20(#218)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge branch 'main' into #93 #93 Merge remote-tracking branch 'origin/main' into #93 #93 #93 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/218 --- frontend/package-lock.json | 3 +- frontend/package.json | 3 +- frontend/src/components/WikiBody.tsx | 60 ++---------- frontend/src/lib/remark-wiki-autolink.ts | 101 +++++++++++++++++++++ frontend/src/pages/wiki/WikiDetailPage.tsx | 2 + frontend/src/pages/wiki/WikiEditPage.tsx | 62 +++++++------ 6 files changed, 150 insertions(+), 81 deletions(-) create mode 100644 frontend/src/lib/remark-wiki-autolink.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43fbc44..764b2a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,7 +29,8 @@ "react-router-dom": "^6.30.0", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.0", + "unist-util-visit-parents": "^6.0.1" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/frontend/package.json b/frontend/package.json index cbe44ff..46cbd07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,7 +31,8 @@ "react-router-dom": "^6.30.0", "react-youtube": "^10.1.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.0", + "unist-util-visit-parents": "^6.0.1" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx index 7e96488..0cb5cef 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -1,6 +1,6 @@ import axios from 'axios' import toCamel from 'camelcase-keys' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import ReactMarkdown from 'react-markdown' import { Link } from 'react-router-dom' import remarkGFM from 'remark-gfm' @@ -8,6 +8,7 @@ import remarkGFM from 'remark-gfm' import SectionTitle from '@/components/common/SectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle' import { API_BASE_URL } from '@/config' +import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' import type { FC } from 'react' import type { Components } from 'react-markdown' @@ -34,17 +35,16 @@ const mdComponents = { h1: ({ children }) => {children} { const [pageNames, setPageNames] = useState ([]) - const [realBody, setRealBody] = useState ('') - useEffect (() => { - if (!(body)) - return + const remarkPlugins = useMemo ( + () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames]) + useEffect (() => { void (async () => { try { const res = await axios.get (`${ API_BASE_URL }/wiki`) - const data = toCamel (res.data as any, { deep: true }) as WikiPage[] + const data: WikiPage[] = toCamel (res.data as any, { deep: true }) setPageNames (data.map (page => page.title).sort ((a, b) => b.length - a.length)) } catch @@ -54,52 +54,8 @@ export default (({ title, body }: Props) => { }) () }, []) - useEffect (() => { - setRealBody ('') - }, [body]) - - useEffect (() => { - if (!(body)) - return - - const matchIndices = (target: string, keyword: string) => { - const indices: number[] = [] - let pos = 0 - let idx - while ((idx = target.indexOf (keyword, pos)) >= 0) - { - indices.push (idx) - pos = idx + keyword.length - } - - return indices - } - - const linkIndices = (text: string, names: string[]): [string, [number, number]][] => { - const result: [string, [number, number]][] = [] - - names.forEach (name => { - matchIndices (text, name).forEach (idx => { - const start = idx - const end = idx + name.length - const overlaps = result.some (([, [st, ed]]) => start < ed && end > st) - if (!(overlaps)) - result.push ([name, [start, end]]) - }) - }) - - return result.sort (([, [a]], [, [b]]) => b - a) - } - - setRealBody ( - linkIndices (body, pageNames).reduce ((acc, [name, [start, end]]) => ( - acc.slice (0, start) - + `[${ name }](/wiki/${ encodeURIComponent (name) })` - + acc.slice (end)), body)) - }, [body, pageNames]) - return ( - - {realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} + + {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} ) }) satisfies FC diff --git a/frontend/src/lib/remark-wiki-autolink.ts b/frontend/src/lib/remark-wiki-autolink.ts new file mode 100644 index 0000000..a37772b --- /dev/null +++ b/frontend/src/lib/remark-wiki-autolink.ts @@ -0,0 +1,101 @@ +import type { Content, Parent, Root, RootContent } from 'mdast' + +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) + + if (names.length === 0) + { + return () => { + ; + } + } + + const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g') + + return (tree: Root) => { + const edits: { parent: Parent; index: number; parts: RootContent[] }[] = [] + + const walk = (node: Content | Root, ancestors: Parent[]) => { + if (!(node) || (typeof node !== 'object')) + 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)) + { + 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 = (node as any).children + 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) + { + 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) + } + } +} diff --git a/frontend/src/pages/wiki/WikiDetailPage.tsx b/frontend/src/pages/wiki/WikiDetailPage.tsx index b5ebd79..6a68656 100644 --- a/frontend/src/pages/wiki/WikiDetailPage.tsx +++ b/frontend/src/pages/wiki/WikiDetailPage.tsx @@ -36,6 +36,7 @@ export default () => { if (/^\d+$/.test (title)) { void (async () => { + setWikiPage (undefined) try { const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`) @@ -52,6 +53,7 @@ export default () => { } void (async () => { + setWikiPage (undefined) try { const res = await axios.get ( diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index 62a2f06..2bda3be 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -12,6 +12,8 @@ import Forbidden from '@/pages/Forbidden' import 'react-markdown-editor-lite/lib/index.css' +import type { FC } from 'react' + import type { User, WikiPage } from '@/types' const mdParser = new MarkdownIt @@ -19,7 +21,7 @@ const mdParser = new MarkdownIt type Props = { user: User | null } -export default ({ user }: Props) => { +export default (({ user }: Props) => { if (!(['admin', 'member'].some (r => user?.role === r))) return @@ -27,8 +29,9 @@ export default ({ user }: Props) => { const navigate = useNavigate () - const [title, setTitle] = useState ('') const [body, setBody] = useState ('') + const [loading, setLoading] = useState (true) + const [title, setTitle] = useState ('') const handleSubmit = async () => { const formData = new FormData () @@ -51,10 +54,12 @@ export default ({ user }: Props) => { useEffect (() => { void (async () => { + setLoading (true) const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`) const data = res.data as WikiPage setTitle (data.title) setBody (data.body) + setLoading (false) }) () }, [id]) @@ -66,30 +71,33 @@ export default ({ user }: Props) => {

Wiki ページを編輯

- {/* タイトル */} - {/* TODO: タグ補完 */} -
- - setTitle (e.target.value)} - className="w-full border p-2 rounded"/> -
- - {/* 本文 */} -
- - mdParser.render (text)} - onChange={({ text }) => setBody (text)}/> -
- - {/* 送信 */} - + {loading ? 'Loading...' : ( + <> + {/* タイトル */} + {/* TODO: タグ補完 */} +
+ + setTitle (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + {/* 本文 */} +
+ + mdParser.render (text)} + onChange={({ text }) => setBody (text)}/> +
+ + {/* 送信 */} + + )}
) -} +}) satisfies FC