From 2550d31e3b61e4b22dd7858e65f0fd1e10c98cc9 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 19 Aug 2025 01:48:01 +0900 Subject: [PATCH 1/3] #93 --- frontend/package-lock.json | 3 +- frontend/package.json | 3 +- frontend/src/components/WikiBody.tsx | 11 ++-- frontend/src/lib/remark-wiki-autolink.ts | 66 ++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/remark-wiki-autolink.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 417dc54..b9f4db2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,7 +25,8 @@ "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", "react-router-dom": "^6.30.0", - "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 7986d56..602e342 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,8 @@ "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", "react-router-dom": "^6.30.0", - "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 e041398..22f716f 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -1,12 +1,13 @@ 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 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 { Components } from 'react-markdown' @@ -34,6 +35,8 @@ export default ({ title, body }: Props) => { const [pageNames, setPageNames] = useState ([]) const [realBody, setRealBody] = useState ('') + const remarkPlugins = useMemo (() => [remarkWikiAutoLink (pageNames)], [pageNames]) + useEffect (() => { if (!(body)) return @@ -42,7 +45,7 @@ export default ({ title, body }: Props) => { 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 @@ -50,9 +53,7 @@ export default ({ title, body }: Props) => { setPageNames ([]) } }) () - }, []) - useEffect (() => { setRealBody ('') }, [body]) @@ -97,7 +98,7 @@ export default ({ title, body }: Props) => { }, [body, pageNames]) return ( - + {realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} ) } diff --git a/frontend/src/lib/remark-wiki-autolink.ts b/frontend/src/lib/remark-wiki-autolink.ts new file mode 100644 index 0000000..9799e37 --- /dev/null +++ b/frontend/src/lib/remark-wiki-autolink.ts @@ -0,0 +1,66 @@ +import { visitParents } from 'unist-util-visit-parents' + +import type { Parent, Root, RootContent, Text } from 'mdast' + +const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&') + + +export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) => { + const names = ([...(new Set(pageNames))] + .filter (Boolean) + .sort ((a, b) => b.length - a.length)) + + if (names.length === 0) + { + return () => { + ; + } + } + + const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g') + + return (tree: Root) => { + visitParents (tree, 'text', (node: Text, ancestors: Parent[]) => { + if (ancestors.some (ancestor => ['link', + 'linkReference', + 'image', + 'imageReference', + 'code', + 'inlineCode'].includes (ancestor.type))) + return + + const value = node.value + re.lastIndex = 0 + let match: RegExpExecArray | null + let last = 0 + const parts: RootContent[] = [] + + while (match = re.exec (value)) + { + const start = match.index + const end = start + match[0].length + if (start > last) + parts.push ({ type: 'text', value: value.slice (last, start) }) + + const name = match[0] + 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 === 0) + return + + if (last < value.length) + parts.push ({ type: 'text', value: value.slice (last) }) + + const parent: Parent = ancestors[ancestors.length - 1] + const idx = parent.children.indexOf (node) + parent.children.splice (idx, 1, ...parts) + }) + } +} -- 2.34.1 From e5f2d4ceb5fa0e1406d357d716736167c2206d5a Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 19 Aug 2025 03:42:09 +0900 Subject: [PATCH 2/3] #93 --- frontend/src/components/WikiBody.tsx | 45 +-------- frontend/src/lib/remark-wiki-autolink.ts | 115 +++++++++++++++-------- frontend/src/pages/wiki/WikiEditPage.tsx | 56 ++++++----- 3 files changed, 107 insertions(+), 109 deletions(-) diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx index 22f716f..a90a9a0 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -33,7 +33,6 @@ const mdComponents = { h1: ({ children }) => {children} { const [pageNames, setPageNames] = useState ([]) - const [realBody, setRealBody] = useState ('') const remarkPlugins = useMemo (() => [remarkWikiAutoLink (pageNames)], [pageNames]) @@ -53,52 +52,10 @@ export default ({ title, body }: Props) => { setPageNames ([]) } }) () - - 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) })。`} ) } diff --git a/frontend/src/lib/remark-wiki-autolink.ts b/frontend/src/lib/remark-wiki-autolink.ts index 9799e37..15a7c29 100644 --- a/frontend/src/lib/remark-wiki-autolink.ts +++ b/frontend/src/lib/remark-wiki-autolink.ts @@ -1,14 +1,10 @@ -import { visitParents } from 'unist-util-visit-parents' - -import type { Parent, Root, RootContent, Text } from 'mdast' +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 = ([...(new Set(pageNames))] - .filter (Boolean) - .sort ((a, b) => b.length - a.length)) + const names = pageNames.sort ((a, b) => b.length - a.length) if (names.length === 0) { @@ -20,47 +16,86 @@ export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g') return (tree: Root) => { - visitParents (tree, 'text', (node: Text, ancestors: Parent[]) => { - if (ancestors.some (ancestor => ['link', - 'linkReference', - 'image', - 'imageReference', - 'code', - 'inlineCode'].includes (ancestor.type))) + const edits: { parent: Parent; index: number; parts: RootContent[] }[] = [] + + const walk = (node: Content | Root, ancestors: Parent[]) => { + if (!(node) || (typeof node !== 'object')) return - const value = node.value - re.lastIndex = 0 - let match: RegExpExecArray | null - let last = 0 - const parts: RootContent[] = [] + 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 - while (match = re.exec (value)) + if (start > last) + parts.push ({ type: 'text', value: value.slice (last, start) }) + + const name = m[0] + 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 start = match.index - const end = start + match[0].length - if (start > last) - parts.push ({ type: 'text', value: value.slice (last, start) }) - - const name = match[0] - parts.push ({ - type: 'link', - url: `${ basePath }/${ encodeURIComponent (name) }`, - title: null, - children: [{ type: 'text', value: name }], - data: { hProperties: { 'data-wiki': '1' } } }) - last = end + 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)) + } } + } - if (parts.length === 0) - return + walk (tree, []) + + for (let i = edits.length - 1; i >= 0; --i) + { + const { parent, index, parts } = edits[i] - if (last < value.length) - parts.push ({ type: 'text', value: value.slice (last) }) + if (!(parent) || !(Array.isArray (parent.children))) + continue - const parent: Parent = ancestors[ancestors.length - 1] - const idx = parent.children.indexOf (node) - parent.children.splice (idx, 1, ...parts) - }) + if (0 <= index && index < parent.children.length) + parent.children.splice (index, 1, ...parts) + } } } diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index 064fa1b..cd57a78 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -27,8 +27,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 +52,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 +69,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)} /> +
+ + {/* 送信 */} + + )}
) } -- 2.34.1 From fbe97ac6348725a7aee9322b41a2e1f20425c443 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 11 Jan 2026 13:39:50 +0900 Subject: [PATCH 3/3] #93 --- frontend/src/components/WikiBody.tsx | 8 +++----- frontend/src/lib/remark-wiki-autolink.ts | 4 ++-- frontend/src/pages/wiki/WikiDetailPage.tsx | 2 ++ frontend/src/pages/wiki/WikiEditPage.tsx | 8 +++++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx index 32ce119..0cb5cef 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -36,12 +36,10 @@ const mdComponents = { h1: ({ children }) => {children} { const [pageNames, setPageNames] = useState ([]) - const remarkPlugins = useMemo (() => [remarkWikiAutoLink (pageNames)], [pageNames]) + const remarkPlugins = useMemo ( + () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames]) useEffect (() => { - if (!(body)) - return - void (async () => { try { @@ -54,7 +52,7 @@ export default (({ title, body }: Props) => { setPageNames ([]) } }) () - }, [body]) + }, []) return ( diff --git a/frontend/src/lib/remark-wiki-autolink.ts b/frontend/src/lib/remark-wiki-autolink.ts index 15a7c29..a37772b 100644 --- a/frontend/src/lib/remark-wiki-autolink.ts +++ b/frontend/src/lib/remark-wiki-autolink.ts @@ -4,7 +4,7 @@ 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 names = [...pageNames].sort ((a, b) => b.length - a.length) if (names.length === 0) { @@ -46,7 +46,7 @@ export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) if (start > last) parts.push ({ type: 'text', value: value.slice (last, start) }) - const name = m[0] + const name = m[1] parts.push ({ type: 'link', url: `${ basePath }/${ encodeURIComponent (name) }`, title: null, 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 e142d2e..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 @@ -93,9 +95,9 @@ export default ({ user }: Props) => { {/* 送信 */} )} ) -} +}) satisfies FC -- 2.34.1