許可する属性 */
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)).'
'.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::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('#'.self::esc($tag).'');
}
}
$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', ''.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('{$inner}
" : $inner; } if ($tag === 'br') return '