|
| 1 | +import { Fragment, type ReactNode } from "react"; |
| 2 | + |
| 3 | +/** |
| 4 | + * 本文中のURLを拾う。終端の判定が要点で、日本語の文章に埋め込まれたURLは |
| 5 | + * 空白で区切られないため「URLに使えない字が出たら終わり」で切る。 |
| 6 | + * 丸括弧を除いているのは、出典の注記のようにURLを括弧でくくって書くことが |
| 7 | + * 多く、閉じ括弧までURLに含めると行き先が404になるため |
| 8 | + * (travel-log-data側は、記事名に含まれる丸括弧をパーセントエンコードして |
| 9 | + * この規則で切れるURLだけを書いている)。日本語の句読点・鉤括弧・全角括弧も |
| 10 | + * 同じ理由で外す |
| 11 | + */ |
| 12 | +const URL_RE = /https?:\/\/[^\s()()「」『』、。,.]+/g; |
| 13 | + |
| 14 | +// 末尾に付きやすい記号を落とす(「…参照。」の句点は上で外れるが、 |
| 15 | +// 半角の . , ; : は URL 自体にも現れるため、末尾のときだけ落とす) |
| 16 | +function trimTail(url: string): { url: string; tail: string } { |
| 17 | + const m = url.match(/[.,;:!?]+$/); |
| 18 | + if (!m) return { url, tail: "" }; |
| 19 | + return { url: url.slice(0, -m[0].length), tail: m[0] }; |
| 20 | +} |
| 21 | + |
| 22 | +/** |
| 23 | + * テキスト中のURLをリンクにして描く。スポット・ルート・訪問予定リストの |
| 24 | + * 説明文に使う —— travel-log-data由来の説明文には出典のWikipedia URLが |
| 25 | + * 入っており、素のテキストのままだと押せないため。 |
| 26 | + * |
| 27 | + * dangerouslySetInnerHTMLは使わない(説明文は管理画面から誰でも書けるので、 |
| 28 | + * HTMLとして解釈させるとそこがXSSの口になる)。href に入れるのも |
| 29 | + * http/https で始まるものだけで、javascript: は正規表現の時点で拾わない |
| 30 | + */ |
| 31 | +export default function LinkedText({ text }: { text: string }) { |
| 32 | + const parts: ReactNode[] = []; |
| 33 | + let last = 0; |
| 34 | + for (const m of text.matchAll(URL_RE)) { |
| 35 | + const start = m.index ?? 0; |
| 36 | + const { url, tail } = trimTail(m[0]); |
| 37 | + if (!url) continue; |
| 38 | + if (start > last) parts.push(text.slice(last, start)); |
| 39 | + parts.push( |
| 40 | + <a |
| 41 | + key={`${start}-${url}`} |
| 42 | + href={url} |
| 43 | + target="_blank" |
| 44 | + rel="noopener noreferrer" |
| 45 | + className="break-all text-blue-600 underline" |
| 46 | + > |
| 47 | + {url} |
| 48 | + </a> |
| 49 | + ); |
| 50 | + if (tail) parts.push(tail); |
| 51 | + last = start + m[0].length; |
| 52 | + } |
| 53 | + if (last === 0) return <>{text}</>; |
| 54 | + if (last < text.length) parts.push(text.slice(last)); |
| 55 | + return ( |
| 56 | + <> |
| 57 | + {parts.map((p, i) => ( |
| 58 | + <Fragment key={i}>{p}</Fragment> |
| 59 | + ))} |
| 60 | + </> |
| 61 | + ); |
| 62 | +} |
0 commit comments