#218 feat: Wiki 自動リンクをテキスト領域のみに制限(#93)

Merged
みてるぞ merged 5 commits from #93 into main 1 month ago
  1. +2
    -1
      frontend/package-lock.json
  2. +2
    -1
      frontend/package.json
  3. +8
    -52
      frontend/src/components/WikiBody.tsx
  4. +101
    -0
      frontend/src/lib/remark-wiki-autolink.ts
  5. +2
    -0
      frontend/src/pages/wiki/WikiDetailPage.tsx
  6. +12
    -4
      frontend/src/pages/wiki/WikiEditPage.tsx

+ 2
- 1
frontend/package-lock.json View File

@@ -29,7 +29,8 @@
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
"react-youtube": "^10.1.0", "react-youtube": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0"
"tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",


+ 2
- 1
frontend/package.json View File

@@ -31,7 +31,8 @@
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
"react-youtube": "^10.1.0", "react-youtube": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0"
"tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",


+ 8
- 52
frontend/src/components/WikiBody.tsx View File

@@ -1,6 +1,6 @@
import axios from 'axios' import axios from 'axios'
import toCamel from 'camelcase-keys' import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import remarkGFM from 'remark-gfm' import remarkGFM from 'remark-gfm'
@@ -8,6 +8,7 @@ import remarkGFM from 'remark-gfm'
import SectionTitle from '@/components/common/SectionTitle' import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle'
import { API_BASE_URL } from '@/config' import { API_BASE_URL } from '@/config'
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'


import type { FC } from 'react' import type { FC } from 'react'
import type { Components } from 'react-markdown' import type { Components } from 'react-markdown'
@@ -34,17 +35,16 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT


export default (({ title, body }: Props) => { export default (({ title, body }: Props) => {
const [pageNames, setPageNames] = useState<string[]> ([]) const [pageNames, setPageNames] = useState<string[]> ([])
const [realBody, setRealBody] = useState<string> ('')


useEffect (() => {
if (!(body))
return
const remarkPlugins = useMemo (
() => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames])


useEffect (() => {
void (async () => { void (async () => {
try try
{ {
const res = await axios.get (`${ API_BASE_URL }/wiki`) 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)) setPageNames (data.map (page => page.title).sort ((a, b) => b.length - a.length))
} }
catch 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 ( return (
<ReactMarkdown components={mdComponents} remarkPlugins={[remarkGFM]}>
{realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
</ReactMarkdown>) </ReactMarkdown>)
}) satisfies FC<Props> }) satisfies FC<Props>

+ 101
- 0
frontend/src/lib/remark-wiki-autolink.ts View File

@@ -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)
}
}
}

+ 2
- 0
frontend/src/pages/wiki/WikiDetailPage.tsx View File

@@ -36,6 +36,7 @@ export default () => {
if (/^\d+$/.test (title)) if (/^\d+$/.test (title))
{ {
void (async () => { void (async () => {
setWikiPage (undefined)
try try
{ {
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`) const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
@@ -52,6 +53,7 @@ export default () => {
} }


void (async () => { void (async () => {
setWikiPage (undefined)
try try
{ {
const res = await axios.get ( const res = await axios.get (


+ 12
- 4
frontend/src/pages/wiki/WikiEditPage.tsx View File

@@ -12,6 +12,8 @@ import Forbidden from '@/pages/Forbidden'


import 'react-markdown-editor-lite/lib/index.css' import 'react-markdown-editor-lite/lib/index.css'


import type { FC } from 'react'

import type { User, WikiPage } from '@/types' import type { User, WikiPage } from '@/types'


const mdParser = new MarkdownIt const mdParser = new MarkdownIt
@@ -19,7 +21,7 @@ const mdParser = new MarkdownIt
type Props = { user: User | null } type Props = { user: User | null }




export default ({ user }: Props) => {
export default (({ user }: Props) => {
if (!(['admin', 'member'].some (r => user?.role === r))) if (!(['admin', 'member'].some (r => user?.role === r)))
return <Forbidden/> return <Forbidden/>


@@ -27,8 +29,9 @@ export default ({ user }: Props) => {


const navigate = useNavigate () const navigate = useNavigate ()


const [title, setTitle] = useState ('')
const [body, setBody] = useState ('') const [body, setBody] = useState ('')
const [loading, setLoading] = useState (true)
const [title, setTitle] = useState ('')


const handleSubmit = async () => { const handleSubmit = async () => {
const formData = new FormData () const formData = new FormData ()
@@ -51,10 +54,12 @@ export default ({ user }: Props) => {


useEffect (() => { useEffect (() => {
void (async () => { void (async () => {
setLoading (true)
const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`) const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`)
const data = res.data as WikiPage const data = res.data as WikiPage
setTitle (data.title) setTitle (data.title)
setBody (data.body) setBody (data.body)
setLoading (false)
}) () }) ()
}, [id]) }, [id])


@@ -66,6 +71,8 @@ export default ({ user }: Props) => {
<div className="max-w-xl mx-auto p-4 space-y-4"> <div className="max-w-xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1> <h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1>


{loading ? 'Loading...' : (
<>
{/* タイトル */} {/* タイトル */}
{/* TODO: タグ補完 */} {/* TODO: タグ補完 */}
<div> <div>
@@ -88,8 +95,9 @@ export default ({ user }: Props) => {
{/* 送信 */} {/* 送信 */}
<button onClick={handleSubmit} <button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
追加
編輯
</button> </button>
</>)}
</div> </div>
</MainArea>) </MainArea>)
}
}) satisfies FC<Props>

Loading…
Cancel
Save