許可する属性 */ private const ALLOWED = [ 'p' => [], 'br' => [], 'a' => ['href', 'class', 'rel'], 'span' => ['class'], 'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [], 'del' => [], 'code' => [], 'pre' => [], 'blockquote' => [], 'ul' => [], 'ol' => [], 'li' => [], ]; /** 中身ごと消すタグ */ private const DROP = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'svg', 'math', 'form', 'textarea', 'select', 'button', 'noscript']; /** 許可するクラス(Mastodonのメンション等) */ private const CLASSES = ['mention', 'hashtag', 'h-card', 'u-url', 'invisible', 'ellipsis']; // 区切り文字なしの正規表現(組み合わせて使う) private const URL = '\bhttps?://[^\s<>"\' \x00]+'; private const MENTION = '(? [user, domain|null]] */ public static function mentions(string $text): array { $text = preg_replace(self::URL_RE, ' ', self::withoutCode($text)); preg_match_all(self::MENTION_RE, $text, $m, PREG_SET_ORDER); $out = []; foreach ($m as $x) $out[$x[0]] = [$x[1], isset($x[2]) && $x[2] !== '' ? strtolower($x[2]) : null]; return $out; } /** * プレーンテキストからハッシュタグを取り出す * * @return array タグ(「#」なし、小文字、重複なし) */ public static function hashtags(string $text): array { $text = preg_replace(self::URL_RE, ' ', self::withoutCode($text)); preg_match_all(self::TAG_RE, $text, $m); return array_values(array_unique(array_map(fn($t) => mb_strtolower($t), $m[1]))); } /** * 本文(Markdown)をHTMLにする * * @param string $text 本文 * @param array $mentionUrls 「@user@domain」 => アクターのURL(解決出来たメンションだけリンクにする) * @param bool $hashtags ハッシュタグをマイクロブログのタグのページへのリンクにする(記事のコメント等ではfalse) * @return string HTML */ public static function render(string $text, array $mentionUrls = [], bool $hashtags = true): string { $text = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], trim($text)); return self::blocks(explode("\n", $text), $mentionUrls, 0, $hashtags); } /** * Markdownのブロック:段落(空行で区切り、改行は
)、```コード```、> 引用、- と 1. のリスト * 見出し・表・画像は他のサーバー(Mastodon等)で表示出来ないので扱わない */ private static function blocks(array $lines, array $mentionUrls, int $depth, bool $hashtags = true): string { $out = ''; $para = []; $flush = function() use (&$para, &$out, $mentionUrls, $hashtags): void { if ($para) $out .= '

'.implode('
', array_map(fn($l) => self::inline($l, $mentionUrls, $hashtags), $para)).'

'; $para = []; }; $n = count($lines); for ($i = 0; $i < $n; $i++) { $line = $lines[$i]; if (str_starts_with($line, '```')) { $flush(); $code = []; for ($i++; $i < $n && !preg_match('/^```\s*$/', $lines[$i]); $i++) $code[] = $lines[$i]; $out .= '
'.self::esc(implode("\n", $code)).'
'; continue; } if ($depth < 3 && preg_match('/^>/', $line)) { $flush(); $quote = []; for (; $i < $n && preg_match('/^> ?(.*)$/', $lines[$i], $m); $i++) $quote[] = $m[1]; $i--; $out .= '
'.self::blocks($quote, $mentionUrls, $depth + 1, $hashtags).'
'; continue; } if (preg_match('/^(?:[-*+]|\d{1,9}[.)])\s+\S/', $line)) { $flush(); $ordered = ctype_digit($line[0]); $item = $ordered ? '/^\d{1,9}[.)]\s+(.*)$/' : '/^[-*+]\s+(.*)$/'; $items = ''; for (; $i < $n && preg_match($item, $lines[$i], $m); $i++) $items .= '
  • '.self::inline($m[1], $mentionUrls, $hashtags).'
  • '; $i--; $out .= $ordered ? "
      {$items}
    " : ""; continue; } if (trim($line) === '') { $flush(); continue; } $para[] = $line; } $flush(); return $out; } /** * 1行の中:`コード`、[文字](URL)、\記号、URL・メンション・ハッシュタグ、**太字**、*斜体*、~~取り消し線~~ * 変換した部分は「\0番号\0」で預かり、エスケープと強調の後で戻す */ private static function inline(string $text, array $mentionUrls, bool $hashtags = true): string { $keep = []; $hold = function(string $html) use (&$keep): string { $keep[] = $html; return "\0".(count($keep) - 1)."\0"; }; $text = preg_replace_callback('/`([^`]+)`/', fn($m) => $hold(''.self::esc($m[1]).''), $text); $text = preg_replace_callback('~\[([^\]]+)\]\((https?://[^\s()<>"\']+)\)~u', fn($m) => $hold(''.self::emphasis(self::esc($m[1])).''), $text); $text = preg_replace_callback('/\\\\([\\\\`*_~\[\]()#@>+-])/', fn($m) => $hold(self::esc($m[1])), $text); // URL・メンション・ハッシュタグを1回で置き換える(URLの中の「#」や「@」を変換しない様に) // グループ:1=URL、2=ユーザー、3=ドメイン、4=タグ $re = '~('.self::URL.')|'.self::MENTION.'|'.self::TAG.'~u'; $out = ''; $pos = 0; preg_match_all($re, $text, $ms, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); foreach ($ms as $m) { [$whole, $at] = $m[0]; $out .= self::esc(substr($text, $pos, $at - $pos)); $pos = $at + strlen($whole); if (($m[1][0] ?? '') !== '') { // 文末の句読点はURLに含めない $url = rtrim($whole, '.,!?)。、」』'); $pos -= strlen($whole) - strlen($url); $shown = preg_replace('#^https?://#', '', $url); if (mb_strlen($shown) > 40) $shown = mb_substr($shown, 0, 40).'…'; $out .= $hold(''.self::esc($shown).''); } else if (($m[2][0] ?? '') !== '') { $url = $mentionUrls[$whole] ?? $mentionUrls[strtolower($whole)] ?? null; // 解決出来なかったメンションはリンクにしない $out .= $hold($url === null ? self::esc($whole) : '@'.self::esc($m[2][0]).''); } else if (!$hashtags) { $out .= self::esc($whole); } else { $tag = $m[4][0]; $out .= $hold(''); } } $out = self::emphasis($out.self::esc(substr($text, $pos))); // 預けた部分の中に預けた部分がある(リンクの中のコード等)ので、無くなるまで戻す for ($k = 0; $k < 4 && str_contains($out, "\0"); $k++) { $out = preg_replace_callback('/\0(\d+)\0/', fn($m) => $keep[(int)$m[1]] ?? '', $out); } return $out; } /** **太字**、__太字__、~~取り消し線~~、*斜体*、_斜体_(「_」は単語の中では使わない:snake_case等) */ private static function emphasis(string $s): string { $s = preg_replace('/\*\*(?=\S)(.+?)(?<=\S)\*\*/u', '$1', $s); $s = preg_replace('/(?$1', $s); $s = preg_replace('/~~(?=\S)(.+?)(?<=\S)~~/u', '$1', $s); $s = preg_replace('/(?$1', $s); return preg_replace('/(?$1', $s); } /** コードの中のメンションとハッシュタグは使わない */ private static function withoutCode(string $text): string { $text = preg_replace('/^```.*?(?:^```[ \t]*$|\z)/msu', ' ', $text); return preg_replace('/`[^`]+`/u', ' ', $text); } /** * リモートのHTMLを安全にする * * @param string $html HTML * @return string 許可したタグと属性だけのHTML */ public static function sanitize(string $html): string { if ($html === '') return ''; if (!class_exists(\DOMDocument::class)) { // DOMが無い環境:タグを全部外す return '

    '.nl2br(self::esc(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')), false).'

    '; } $doc = new \DOMDocument(); $prev = libxml_use_internal_errors(true); $doc->loadHTML('
    '.$html.'
    ', LIBXML_NONET | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); libxml_use_internal_errors($prev); $root = $doc->getElementById('root') ?? $doc->documentElement; if (!$root) return ''; $out = ''; foreach ($root->childNodes as $child) $out .= self::clean($child); return $out; } /** * HTMLからタグを外した文(検索や通知用) */ public static function text(string $html, int $max = 0): string { $html = preg_replace('#|

    #i', "\n", $html); $t = trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); if ($max > 0 && mb_strlen($t) > $max) $t = mb_substr($t, 0, $max).'…'; return $t; } public static function esc(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); } /////// private static function clean(\DOMNode $node): string { if ($node instanceof \DOMText) return self::esc($node->nodeValue ?? ''); if (!$node instanceof \DOMElement) return ''; $tag = strtolower($node->nodeName); if (in_array($tag, self::DROP, true)) return ''; $inner = ''; foreach ($node->childNodes as $c) $inner .= self::clean($c); // 許可しないタグは中身だけ残す(見出し等は段落にする) if (!isset(self::ALLOWED[$tag])) { return in_array($tag, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div'], true) ? "

    {$inner}

    " : $inner; } if ($tag === 'br') return '
    '; $attrs = ''; foreach (self::ALLOWED[$tag] as $name) { if (!$node->hasAttribute($name)) continue; $v = trim($node->getAttribute($name)); if ($name === 'href') { if (!preg_match('#^https?://#i', $v)) continue; } else if ($name === 'class') { $v = implode(' ', array_intersect(preg_split('/\s+/', $v), self::CLASSES)); if ($v === '') continue; } else if ($name === 'rel') { continue; // 下で付ける } $attrs .= ' '.$name.'="'.self::esc($v).'"'; } if ($tag === 'a') $attrs .= ' rel="nofollow noopener noreferrer"'; return "<{$tag}{$attrs}>{$inner}"; } }