| @@ -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", | |||
| @@ -33,7 +33,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", | |||
| @@ -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 }) => <SectionTitle>{children}</SectionT | |||
| export default (({ title, body }: Props) => { | |||
| const [pageNames, setPageNames] = useState<string[]> ([]) | |||
| const [realBody, setRealBody] = useState<string> ('') | |||
| 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 ( | |||
| <ReactMarkdown components={mdComponents} remarkPlugins={[remarkGFM]}> | |||
| {realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||
| <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | |||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||
| </ReactMarkdown>) | |||
| }) satisfies FC<Props> | |||
| @@ -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) | |||
| } | |||
| } | |||
| } | |||
| @@ -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 ( | |||
| @@ -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 <Forbidden/> | |||
| @@ -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) => { | |||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | |||
| <h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1> | |||
| {/* タイトル */} | |||
| {/* TODO: タグ補完 */} | |||
| <div> | |||
| <label className="block font-semibold mb-1">タイトル</label> | |||
| <input type="text" | |||
| value={title} | |||
| onChange={e => setTitle (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* 本文 */} | |||
| <div> | |||
| <label className="block font-semibold mb-1">本文</label> | |||
| <MdEditor value={body} | |||
| style={{ height: '500px' }} | |||
| renderHTML={text => mdParser.render (text)} | |||
| onChange={({ text }) => setBody (text)}/> | |||
| </div> | |||
| {/* 送信 */} | |||
| <button onClick={handleSubmit} | |||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||
| 追加 | |||
| </button> | |||
| {loading ? 'Loading...' : ( | |||
| <> | |||
| {/* タイトル */} | |||
| {/* TODO: タグ補完 */} | |||
| <div> | |||
| <label className="block font-semibold mb-1">タイトル</label> | |||
| <input type="text" | |||
| value={title} | |||
| onChange={e => setTitle (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* 本文 */} | |||
| <div> | |||
| <label className="block font-semibold mb-1">本文</label> | |||
| <MdEditor value={body} | |||
| style={{ height: '500px' }} | |||
| renderHTML={text => mdParser.render (text)} | |||
| onChange={({ text }) => setBody (text)}/> | |||
| </div> | |||
| {/* 送信 */} | |||
| <button onClick={handleSubmit} | |||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||
| 編輯 | |||
| </button> | |||
| </>)} | |||
| </div> | |||
| </MainArea>) | |||
| } | |||
| }) satisfies FC<Props> | |||