Skip to content

Commit f46d1fc

Browse files
committed
説明文の中のURLをリンクにする
travel-log-data由来の説明文が、末尾に出どころのWikipediaページのURLを 持つようになった。素のテキストのままだと押せず、URLを手で選んで コピーすることになるので、共通部品LinkedTextで <a> にして描く。 使うのはスポット詳細・訪問予定への追加確認・経路詳細・区間の説明・ 訪問予定リストの説明の5か所。 URLの終端は「空白か括弧が出たら終わり」で切る。日本語の文に埋め込まれた URLは空白で区切られないので、URLに使えない字を見て切るしかない。 丸括弧を外しているのは、出典の注記のようにURLを括弧でくくるのが普通で、 閉じ括弧まで含めると行き先が404になるため。データ側はこの規則に合わせて 記事名の丸括弧を %28/%29 にしてから書いているので、片方だけ直すと壊れる。 dangerouslySetInnerHTMLは使わない。説明文は管理画面から誰でも書けるので、 HTMLとして解釈させるとそこがXSSの口になる。hrefに入るのも正規表現が 拾った http(s):// だけで、javascript: はそもそも一致しない。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7a0f68c commit f46d1fc

5 files changed

Lines changed: 80 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,14 @@ Microsoft Copilotは`?q=`が入力欄に入らなくなる回帰が報告され
433433

434434
既定の`absolute``overflow`で切り取る箱(スクロールする一覧、角丸のための`overflow-hidden`)の中に置くと欠ける。**`anchored`はその回避でありながら「?」の近くに出せる**ので、そういう場所では基本こちらを使う(`sheet`は画面の下端まで目線が飛び、どの「?」の説明かが読み取りにくい)。位置は開いた時点の`getBoundingClientRect`で決め、スクロール(capture付き)とリサイズで追従させる。**位置が決まるまでは`invisible`にする** —— 一瞬だけ画面の左上に出てから飛ぶため。
435435

436+
### 説明文の中のURLをリンクにする(`LinkedText`)
437+
438+
スポット・経路・訪問予定リストの説明文に出てくる`http(s)://…``<a>`にして描く共通部品。travel-log-data由来の説明文は末尾に出どころのWikipediaページのURLを持っており(`(出典: ja.wikipedia「北海道神宮」 https://…)`)、素のテキストのままだと押せないため。使うのは`SpotDetailModal`(スポット詳細)・`MapView`(訪問予定への追加確認・経路詳細・区間の説明)・`VisitPlanListDetailModal`(リストの説明)。
439+
440+
- **URLの終端は「空白か括弧が出たら終わり」で切る。** 日本語の文に埋め込まれたURLは空白で区切られないので、URLに使えない字を見て切るしかない。丸括弧を外しているのは、出典の注記のようにURLを括弧でくくるのが普通で、閉じ括弧まで含めると行き先が404になるため。**travel-log-data側はこの規則に合わせて、記事名の丸括弧を`%28`/`%29`にしてから書いている**(片方だけ直すと壊れる)。日本語の句読点・鉤括弧・全角括弧も同じ理由で外す
441+
- **`dangerouslySetInnerHTML`は使わない。** 説明文は管理画面から誰でも書けるので、HTMLとして解釈させるとそこがXSSの口になる。`href`に入るのも正規表現が拾った`http(s)://…`だけで、`javascript:`はそもそも一致しない
442+
- **説明文を出すところを増やしたら、ここも通すこと。** 素の`{spot.description}`のままだとその画面だけURLが押せない
443+
436444
### 用語: 「経路」と、記録から引かれる線
437445

438446
**画面の呼称は「経路」**。管理画面/CSV から取り込む、スポットを巡った順に矢印で繋いだデータのこと(`spot_routes`)。**かつては画面でも「ルート」と呼んでいたが、`route` の訳がそのまま「経路」で、記録から引かれる線(こちらも「経路」と呼んでいた)と区別が付かなかったため統一した。**

components/LinkedText.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+
}

components/MapView.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Link from "next/link";
55
import { useRouter, useSearchParams } from "next/navigation";
66
import PlanBuildPanel from "@/components/PlanBuildPanel";
77
import HelpTip from "@/components/HelpTip";
8+
import LinkedText from "@/components/LinkedText";
89
import WeatherAskLink from "@/components/WeatherAskLink";
910
import VisitPlanListFormModal from "@/components/VisitPlanListFormModal";
1011
import { useNavVisibility } from "@/components/AppFrame";
@@ -4205,7 +4206,7 @@ export default function MapView({
42054206
</p>
42064207
{spot?.description && (
42074208
<p className="whitespace-pre-wrap text-sm text-gray-600">
4208-
{spot.description}
4209+
<LinkedText text={spot.description} />
42094210
</p>
42104211
)}
42114212
{/* 取り直しが済むまでは出さない(種別が分かるまで可否を
@@ -4381,7 +4382,7 @@ export default function MapView({
43814382
</div>
43824383
{routeDetailView.description && (
43834384
<p className="whitespace-pre-wrap text-sm text-gray-700">
4384-
{routeDetailView.description}
4385+
<LinkedText text={routeDetailView.description} />
43854386
</p>
43864387
)}
43874388
{routeDetailView.points.length > 0 && (
@@ -4464,7 +4465,7 @@ export default function MapView({
44644465
<span className="w-6 shrink-0 text-right"></span>
44654466
{point.legDescription && (
44664467
<span className="min-w-0 whitespace-pre-wrap">
4467-
{point.legDescription}
4468+
<LinkedText text={point.legDescription} />
44684469
</span>
44694470
)}
44704471
</div>

components/SpotDetailModal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { formatPlanDateRange } from "@/lib/planListDraft";
2525
import { buildGeminiAskUrl } from "@/lib/askAi";
2626
import SpotBadge from "@/components/SpotBadge";
2727
import MiniMap from "@/components/MiniMap";
28+
import LinkedText from "@/components/LinkedText";
2829
import { resolveSeriesStyles } from "@/lib/seriesStyle";
2930
import {
3031
resolveWikipediaLang,
@@ -562,7 +563,9 @@ export default function SpotDetailModal({
562563
</div>
563564

564565
{spot.description && (
565-
<p className="mb-3 text-sm text-gray-700">{spot.description}</p>
566+
<p className="mb-3 whitespace-pre-wrap text-sm text-gray-700">
567+
<LinkedText text={spot.description} />
568+
</p>
566569
)}
567570

568571
<div className="relative">

components/VisitPlanListDetailModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { formatSpotMeta } from "@/lib/spotMeta";
99
import type { Spot, VisitPlanList } from "@/lib/types";
1010
import type { SeriesStyleDefinition } from "@/lib/seriesStyle";
1111
import SpotBadge from "@/components/SpotBadge";
12+
import LinkedText from "@/components/LinkedText";
1213
import HelpTip from "@/components/HelpTip";
1314
import GoogleMapsRouteLink from "@/components/GoogleMapsRouteLink";
1415
import WeatherAskLink from "@/components/WeatherAskLink";
@@ -231,7 +232,7 @@ export default function VisitPlanListDetailModal({
231232

232233
{list.description && (
233234
<p className="mb-3 whitespace-pre-wrap text-sm text-gray-700">
234-
{list.description}
235+
<LinkedText text={list.description} />
235236
</p>
236237
)}
237238

0 commit comments

Comments
 (0)