@@ -25,7 +25,8 @@ | |||||
"react-markdown": "^10.1.0", | "react-markdown": "^10.1.0", | ||||
"react-markdown-editor-lite": "^1.3.4", | "react-markdown-editor-lite": "^1.3.4", | ||||
"react-router-dom": "^6.30.0", | "react-router-dom": "^6.30.0", | ||||
"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", | ||||
@@ -27,7 +27,8 @@ | |||||
"react-markdown": "^10.1.0", | "react-markdown": "^10.1.0", | ||||
"react-markdown-editor-lite": "^1.3.4", | "react-markdown-editor-lite": "^1.3.4", | ||||
"react-router-dom": "^6.30.0", | "react-router-dom": "^6.30.0", | ||||
"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", | ||||
@@ -1,12 +1,13 @@ | |||||
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 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 { Components } from 'react-markdown' | import type { Components } from 'react-markdown' | ||||
@@ -34,6 +35,8 @@ export default ({ title, body }: Props) => { | |||||
const [pageNames, setPageNames] = useState<string[]> ([]) | const [pageNames, setPageNames] = useState<string[]> ([]) | ||||
const [realBody, setRealBody] = useState<string> ('') | const [realBody, setRealBody] = useState<string> ('') | ||||
const remarkPlugins = useMemo (() => [remarkWikiAutoLink (pageNames)], [pageNames]) | |||||
useEffect (() => { | useEffect (() => { | ||||
if (!(body)) | if (!(body)) | ||||
return | return | ||||
@@ -42,7 +45,7 @@ export default ({ title, body }: Props) => { | |||||
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 | ||||
@@ -50,9 +53,7 @@ export default ({ title, body }: Props) => { | |||||
setPageNames ([]) | setPageNames ([]) | ||||
} | } | ||||
}) () | }) () | ||||
}, []) | |||||
useEffect (() => { | |||||
setRealBody ('') | setRealBody ('') | ||||
}, [body]) | }, [body]) | ||||
@@ -97,7 +98,7 @@ export default ({ title, body }: Props) => { | |||||
}, [body, pageNames]) | }, [body, pageNames]) | ||||
return ( | return ( | ||||
<ReactMarkdown components={mdComponents}> | |||||
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | |||||
{realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | {realBody || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | ||||
</ReactMarkdown>) | </ReactMarkdown>) | ||||
} | } |
@@ -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) | |||||
}) | |||||
} | |||||
} |