Reactで、文章の中のURLを自動でリンクにする方法

​​React​JavaScript,Playwright,TypeScript,正規表現

文章の中の URL をリンクにするには、文章を「ふつうの文字」と「URL」の部分に分けた配列を作り、URL の部分だけを <a> にして描画します。文字列のまま React に渡すので、HTML の文字列を組み立てて dangerouslySetInnerHTML に入れる必要はありません。入力された文章に <img> などのタグが書かれていても、タグとしては働かず、そのまま文字として表示されます。

ここでは、文章を分ける関数 splitLinks と、それを使って表示する AutoLinkText という部品を作りました。対象にするのは http:// と https:// で始まる URL です。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、StrictMode で包んだ開発用のビルドを Playwright で操作しました。www. で始まる URL やメールアドレスのリンク化、Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げは試していません。

上に「文章」の入力欄、下に「表示」の欄がある。表示の欄では、https://example.com/docs/getting-started と、全角のかっこで囲んだ https://example.com/faq と、その下の長い URL の3つが、下線付きの水色のリンクになり、かっこや「にあります。」はふつうの文字のまま。長い URL は枠の中で折り返されている
入力欄の文章を、URL だけリンクにして下に表示したところ

文章を、文字と URL の部分に分ける

分ける処理は、linkify.ts にまとめました。

ts
// 文章を「ふつうの文字」と「URL」の部分に分ける関数

export type Part =
  | { type: 'text'; text: string }
  | { type: 'link'; text: string; href: string };

// http:// か https:// で始まり、URL に使える ASCII の文字が続く部分
const URL_PATTERN = /https?:\/\/[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+/g;

// 文の終わりの「.」や、URL を囲んだ「)」を URL から外す
function trimEnd(url: string): string {
  let end = url.length;
  while (end > 0) {
    const last = url[end - 1];
    if ('.,!?;:\''.includes(last)) {
      end--;
    } else if (last === ')') {
      const head = url.slice(0, end);
      const open = head.split('(').length - 1;
      const close = head.split(')').length - 1;
      if (close <= open) break; // 対になる「(」があれば URL の一部
      end--;
    } else {
      break;
    }
  }
  return url.slice(0, end);
}

export function splitLinks(text: string): Part[] {
  const parts: Part[] = [];
  let last = 0;
  for (const match of text.matchAll(URL_PATTERN)) {
    const url = trimEnd(match[0]);
    if (!URL.canParse(url)) continue; // 「https://」だけ、などは文字のまま
    const start = match.index;
    if (start > last) parts.push({ type: 'text', text: text.slice(last, start) });
    parts.push({ type: 'link', text: url, href: url });
    last = start + url.length;
  }
  if (last < text.length) parts.push({ type: 'text', text: text.slice(last) });
  return parts;
}

matchAll() は、正規表現に合った部分を順に返します。1つ1つの結果には、合った文字列(match[0])と、文章の先頭から数えて、それが始まる位置(0 から始まる match.index)が入っています。前に見つけた URL の終わり(last)から次の URL の始まりまでを文字の部分、URL そのものをリンクの部分として、配列に順に入れていきます。

正規表現の [...] の中には、URL にそのまま書ける ASCII の文字だけを並べました。こうしておくと、日本語の文章で URL のすぐ後に文字が続いても、URL の終わりが分かります。検証環境で試したところ、「https://example.comを見てください」では https://example.com だけがリンクになりました。全角のかっこで囲んだ「(https://example.com/faq)」も、かっこを含まずにリンクになります。

URL.canParse() は、MDN の説明(英語)にあるとおり、new URL() で作れる文字列なら true、作れずに例外になる文字列なら false を返します。「https:// だけ」のように、正規表現には合っても URL として成り立たないものは、リンクにせず文字のまま残します。

文の終わりの記号と、半角のかっこ

英語の文や、半角で書いた文では、URL のすぐ後に「.」や「)」が付くことがあります。これらは URL に使える文字でもあるので、正規表現だけでは URL の一部として拾われてしまいます。そこで trimEnd で、URL の最後に付いた . , ! ? ; : ' を外しています。

) は少し扱いが違います。https://en.wikipedia.org/wiki/Cat_(disambiguation) のように、URL の中に ( と ) が対になって入っていることがあるからです。URL の中の ( の数より ) の数が多いときだけ、最後の ) を外すようにしました。

検証環境で splitLinks に通した結果は次のとおりです(文字の部分は省き、リンクになった部分だけを書いています)。

入力した文章 リンクになった部分
URLは https://example.com/path. です https://example.com/path
(see https://en.wikipedia.org/wiki/Cat_(disambiguation)) https://en.wikipedia.org/wiki/Cat_(disambiguation)
https://example.com/?q=a&b=c, http://example.com/! https://example.com/?q=a&b=c と http://example.com/
javascript:alert(1) と ftp://example.com なし
https://ja.wikipedia.org/wiki/猫 https://ja.wikipedia.org/wiki/

最後の行のように、パスに日本語がそのまま入った URL は、日本語の手前でリンクが切れます。この記事では、URL のすぐ後に日本語が続いても URL の終わりが分かるように、ASCII の文字だけを URL とみなす書き方にしました。同じ URL でも、https://ja.wikipedia.org/wiki/%E7%8C%AB のように %記法で書かれていれば、最後までリンクになります。

javascript: や ftp:// で始まる文字列は、正規表現が https?:// で始まるものしか探さないので、リンクになりません。

配列のまま描画して、タグを文字として表示する

表示する部品が AutoLinkText.tsx です。

tsx
import { splitLinks } from './linkify';

type Props = {
  text: string;
};

// 文章の中の URL だけを <a> にして表示する
export function AutoLinkText({ text }: Props) {
  return (
    <p className="autolink">
      {splitLinks(text).map((part, i) =>
        part.type === 'link' ? (
          <a key={i} href={part.href} target="_blank" rel="noopener noreferrer">
            {part.text}
          </a>
        ) : (
          part.text
        ),
      )}
    </p>
  );
}

文字の部分は文字列のまま、URL の部分は <a> 要素にして、1つの配列として <p> の中に置きます。配列の中の文字列は、React がテキストとして DOM に入れます。

検証環境で、入力欄に <b>太字</b><img src=x onerror=alert(1)> https://example.com/ と入れたところ、表示の欄の innerHTML は次のようになりました。

text
&lt;b&gt;太字&lt;/b&gt;&lt;img src=x onerror=alert(1)&gt; <a href="https://example.com/" target="_blank" rel="noopener noreferrer">https://example.com/</a>

<b> と <img> は &lt; に置き換えられた文字になり、要素としては1つもできませんでした。リンクになったのは URL の部分だけです。

同じことを、URL を <a href="..."> に置き換えた HTML の文字列を作って dangerouslySetInnerHTML に渡す方法でやると、文章の中のタグもそのまま HTML として扱われます。React の公式リファレンスにある dangerouslySetInnerHTML の説明では、ユーザーが入力したような信頼できない HTML を渡すと XSS 脆弱性の原因になり、信頼できるサニタイズ済みのデータにだけ使うよう書かれています。文字と <a> の配列にする方法なら、HTML を組み立てる部分がありません。

key には配列のインデックス(i)を使いました。この配列は text から毎回作り直すもので、<a> の中に state もありません。文字列の部分には key を付けていませんが、検証環境の開発用のビルドでは、key についての警告はコンソールに出ませんでした。

新しいタブで開くときの rel

リンクには target="_blank" と rel="noopener noreferrer" を付けて、新しいタブで開くようにしました。MDN の rel="noopener" の説明では、noopener を付けると、開いたページの window.opener が null になり、元のページを操作できなくなるとされています。rel="noreferrer" を付けると、リンク先に Referer ヘッダーを送りません。

検証環境で、リンクをクリックして開いたページで window.opener と document.referrer を調べたところ、{"opener":null,"referrer":""} でした。元のページはそのまま残っていました。

なお、MDN の <a> 要素の説明によると、新しい版のブラウザーでは target="_blank" だけでも rel="noopener" と同じ動きになります。この記事では、利用者が書いた、行き先の分からない URL を開くので、noreferrer も付けて明示しました。

改行と長い URL を、枠の中に収める

入力欄の文章をそのまま表示に渡しているので、改行は \n の文字として残ります。表示の側では CSS の white-space: pre-wrap で改行を表示しました。

css
.autolink {
  margin: 0;
  padding: 8px;
  background: #eefafd;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.autolink a {
  color: #067a98;
}

URL には空白がないので、長い URL は1つの長い単語として扱われ、そのままでは途中で折り返されません。overflow-wrap: anywhere を付けると、ほかに折り返せる場所がないときに、単語の途中でも折り返します。

検証環境で幅 400px の画面に表示したところ、overflow-wrap: anywhere があるときの表示の欄の幅は 360px で、中身もその幅に収まりました。normal に変えると、長い URL に合わせて欄が 534px まで広がり、画面の幅を超えました。

入力欄とつないで、打つたびにリンクにする

最初の画像の画面は、次の CommentPreview という部品で表示しています。入力欄の文章を state に持ち、それを AutoLinkText に渡しただけのものです。

tsx
import { useState } from 'react';
import { AutoLinkText } from './AutoLinkText';

type Props = {
  defaultValue?: string;
};

// 入力欄に書いた文章を、URL をリンクにして下に表示する
export function CommentPreview({ defaultValue = '' }: Props) {
  const [text, setText] = useState(defaultValue);
  return (
    <div className="preview">
      <label>
        文章
        <textarea rows={7} value={text} onChange={(e) => setText(e.target.value)} />
      </label>
      <p className="preview-title">表示</p>
      <AutoLinkText text={text} />
    </div>
  );
}

リンクにした結果は state に入れず、描画のたびに splitLinks で作ります。入力した文章さえあれば決まるものなので、別の state に入れて、入力と同期させる必要がありません。

空の入力欄に https://example.com/a と1文字ずつ打ったところ、https:// まで打った時点ではリンクにならず、https://exa まで打った時点ではリンクになっていました。そのあとも、確かめた時点(https://exam、https://example.com、https://example.com/)では、どれもそれまでに打った部分がリンクになっていました。最後は https://example.com/a 全体がリンクになりました。打っている途中の文字列も、その時点で URL として成り立てばリンクとして表示されます。

​Posted by 86