Files
LittleBeast/src/Std/Lib/Markdown.php
2026-04-29 00:37:32 +09:00

686 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/*************************************************************
# 076 License
Copyright (c) テクニカル諏訪子
Permission is hereby granted to any person obtaining a copy of the software
Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
sublicense, and/or sell copies of the Software, subject to the following conditions:
1. **Origin Attribution**:
- You must not misrepresent the origin of the Software; you must not claim
you created the original Software.
- If the Software is used in a product, you must either:
a. Provide clear attribution in the product's documentation, user interface,
or other visible areas, **OR**
b. Pay the original developers a fee they specify in writing.
2. **Usage Restriction**:
- The Software, or any derivative works, dependencies, or libraries
incorporating it, must not be used for censorship or to suppress freedom of
speech, expression, or creativity. Prohibited uses include, but are not
limited to:
- Censorship of so-called "hate speech", visuals, non-mainstream opinions,
ideas, or objective reality.
- Tools or systems designed to restrict access to information or
artistic works.
3. **Notice Preservation**:
- This license and the above copyright notice must remain intact in all copies
of the source code.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
namespace Std\Lib;
class Markdown {
private string $content;
private array $html;
private string $path;
private bool $inCodeBlock = false;
private string $codeBlockLanguage = '';
private array $codeBlockContent = [];
private const METADATA_LINE = "----";
private array $algebraicPlaceholder = [];
public function __construct(string $path, string $section, ?string $lang = null) {
$this->html = [];
if ($lang) $this->path = ROOT.$section.$lang.'/'.$path.'.md';
else $this->path = ROOT.$section.$path.'.md';
if (!file_exists($this->path)) {
header('Location: /404');
exit();
}
}
/**
* メタデータを取得する
*
* @return \stdClass メタデータオブジェクト
*/
public function getMetadata(): \stdClass {
$content = file_get_contents($this->path);
$metadata = new \stdClass();
$parts = explode(self::METADATA_LINE, $content, 2);
if (count($parts) < 2) return $metadata;
$lines = explode("\n", trim($parts[0]));
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
$colonPos = strpos($line, ':');
if ($colonPos === false) continue;
$key = trim(substr($line, 0, $colonPos));
$value = trim(substr($line, $colonPos + 1));
$value = trim($value, '"\'');
if ($key == 'category' || $key == 'css') {
$cat = explode(',', $value);
$value = $cat;
}
$metadata->$key = $value;
}
return $metadata;
}
/**
* Markdownをパースする
*
* @return string HTMLとしてレンダリングされたコンテンツ
*/
public function parse(): string {
$content = file_get_contents($this->path);
$parts = explode(self::METADATA_LINE, $content, 2);
$this->content = count($parts) > 1 ? trim($parts[1]): trim($content);
$this->html = [];
$lines = explode("\n", $this->content);
$currentParagraph = [];
$inList = false;
$listItems = [];
$listLevel = 0;
$inBlockquote = false;
$blockquoteContent = [];
$tableHeaders = [];
$tableRows = [];
$inTable = false;
$i = 0;
$headerCount = 1;
foreach ($lines as $line) {
$i++;
$hasBR = substr($line, -1) === '\\';
$line = rtrim($line, " \t\r\n\\");
// コメント
// if (str_starts_with($line, '//')) {
// if ($hasBR && !empty($currentParagraph)) {
// $currentParagraph[] = '';
// }
// continue;
// }
// コードブロック
if (preg_match('/^```(\w*)$/', $line, $matches)) {
if (!$this->inCodeBlock) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
$currentParagraph = [];
}
$this->inCodeBlock = true;
$this->codeBlockLanguage = $matches[1];
continue;
} else {
$this->html[] = $this->createCodeBlock();
$this->inCodeBlock = false;
$this->codeBlockLanguage = '';
$this->codeBlockContent = [];
continue;
}
}
if ($this->inCodeBlock) {
$this->codeBlockContent[] = $line;
continue;
}
// テーブルの処理
if (preg_match('/^\|(.+)\|$/', $line)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
$currentParagraph = [];
}
$cells = array_map('trim', explode('|', trim($line, '|')));
if (!$inTable) {
$tableHeaders = $cells;
$inTable = true;
} elseif (preg_match('/^\|(\s*:?-+:?\s*\|)+$/', $line)) {
continue;
} else {
$tableRows[] = $cells;
}
continue;
} elseif ($inTable) {
$this->html[] = $this->createTable($tableHeaders, $tableRows);
$tableHeaders = [];
$tableRows = [];
$inTable = false;
}
// 水平線の処理
if (preg_match('/^([\-\*\_])\1{2,}$/', $line)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
$currentParagraph = [];
}
$this->html[] = " <hr />\n";
continue;
}
// 引用ブロックの処理
if (preg_match('/^>\s(.+)/', $line, $matches)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
$currentParagraph = [];
}
$inBlockquote = true;
$blockquoteContent[] = $this->parseInline($matches[1]);
continue;
} elseif ($inBlockquote && empty($line)) {
$this->html[] = $this->createBlockquote($blockquoteContent);
$blockquoteContent = [];
$inBlockquote = false;
continue;
}
// 空行をスキップ
if (empty($line)) {
if ($inList) {
$this->html[] = $this->createList($listItems);
$listItems = [];
$inList = false;
}
if (!empty($currentParagraph)) {
$this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
$currentParagraph = [];
}
continue;
}
// ヘッダー
if (preg_match('/^(#{1,6})\s(.+)/', $line, $m)) {
if ($inList) {
$this->html[] = $this->createList($listItems);
$listItems = [];
$inList = false;
}
if (!empty($currentParagraph)) {
$this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
$currentParagraph = [];
}
$level = strlen($m[1]);
$this->html[] = " <h{$level} id=\"section-{$headerCount}\">".$this->parseInline($m[2])."</h{$level}>";
$headerCount++;
continue;
}
// 箇条書きリスト
if (preg_match('/^(\s*)([\*\-])\s(.+)/', $line, $m)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
$currentParagraph = [];
}
$inList = true;
$currentLevel = strlen($m[1]) / 2;
$listLevel = max($listLevel, $currentLevel);
$listItems[] = [
'content' => $this->parseInline($m[3]),
'level' => $currentLevel,
'type' => 'ul',
];
continue;
}
// 番号付きリスト
if (preg_match('/^(\s*)\d+\.\s(.+)/', $line, $m)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
$currentParagraph = [];
}
$inList = true;
$currentLevel = strlen($m[1]) / 2;
$listLevel = max($listLevel, $currentLevel);
$listItems[] = [
'content' => $this->parseInline($m[2]),
'level' => $currentLevel,
'type' => 'ol',
];
continue;
}
if ($inList) {
$this->html[] = $this->createList($listItems);
$listItems = [];
$inList = false;
$listLevel = 0;
}
$parsedLine = $this->parseInline($line);
$currentParagraph[] = $parsedLine;
if ($hasBR) {
$currentParagraph[] = "<br />\n ";
}
}
if ($inList) $this->html[] = $this->createList($listItems);
if ($inBlockquote) $this->html[] = $this->createBlockquote($blockquoteContent);
if ($inTable) $this->html[] = $this->createTable($tableHeaders, $tableRows);
if (!empty($currentParagraph))
$this->html[] = " <p>\n ".implode("", $currentParagraph)."\n </p>";
return implode("\n", $this->html);
}
// 機能性メソッド
/**
* インラインのMarkdown記法をパースする
*
* @param string $text パースするテキスト
* @return string HTMLとしてレンダリングされたテキスト
*/
private function parseInline(string $text): string {
$this->algebraicPlaceholder = [];
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
$patterns = [
// 数式
'/(?<!\\\\)\$\$([^$]+)\$\$/u' => function($matches): string {
$placeholder = "{{ALG".count($this->algebraicPlaceholder).'ALG}}';
$this->algebraicPlaceholder[$placeholder] = $matches[1];
return $placeholder;
},
// 太字
'/(?<!\\\\)\*\*(.+?)\*\*/u' => '<strong>$1</strong>',
// 斜体
'/(?<!\\\\)\*(.+?)\*/u' => '<em>$1</em>',
// 下線
'/(?<!\\\\)_(.+?)_/u' => '<u>$1</u>',
// 取り消し線
'/(?<!\\\\)~(.+?)~/u' => '<s>$1</s>',
// Blink (with speed)
'/(?<!\\\\)!:\((.+?)\)(.+?):!/u' => '<span class="blink" style="animation-duration: $1s;">$2</span>',
// Blink
'/(?<!\\\\)!:(.+?):!/u' => '<span class="blink">$1</span>',
// フォントの大きさ
'/(?<!\\\\)\^\((.+?)\)(.+?)\^/u' => '<span style="font-size: $1px;">$2</span>',
// フォントカラー
'/(?<!\\\\)\%\((.+?)\)(.+?)\%/u' => '<span style="color: #$1;">$2</span>',
// 画像
'/(?<!\\\\)\!\[(.*?)(?:#([^\]]*))?\]\((.+?)\)/u' => '<img style="width: 100%; $2" src="$3" alt="$1" />',
// 音楽
'/(?<!\\\\)\$\[([^\]]+)\]\(([^\)]+)\)/u' => '<audio controls><source src="$2" type="$1" /></audio>',
// 動画
'/(?<!\\\\)\#\[([^\]]+)\]\(([^\)]+)\)/u' => '<video style="max-width: 100%;" controls><source src="$2" type="$1" /></video>',
// リンク
'/(?<!\\\\)\[(.+?)\]\((.+?)\)/u' => '<a href="$2">$1</a>',
// 振り仮名
'/(?<!\\\\)&lt;(.+?)&gt;\((.+?)\)/u' => '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>',
// インラインコード
'/(?<!\\\\)`(.+?)`/u' => '<code>$1</code>',
];
foreach ($patterns as $pattern => $replacement) {
if (is_callable($replacement)) {
$text = preg_replace_callback($pattern, $replacement, $text);
} else {
$result = preg_replace($pattern, $replacement, $text);
if ($result === null) continue;
$text = $result;
}
}
// プレースホルダーの数式に交換
foreach ($this->algebraicPlaceholder as $placeholder => $expr) {
$text = str_replace($placeholder, $this->parseAlgebraic($expr), $text);
}
$text = preg_replace('/\\\\(.)/u', '$1', $text);
return $text;
}
/**
* 数式記法をパースする
*
* @param string $expression パースする数式
* @return string MathJax用にフォーマットされた数式
*/
private function parseAlgebraic(string $expression): string {
// HTMLエスケープ
$expression = htmlspecialchars_decode($expression, ENT_QUOTES);
// ふぃりしゃもじのマッピング
$greekLetters = [
'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ', 'delta' => 'δ',
'epsilon' => 'ε', 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ',
'iota' => 'ι', 'kappa' => 'κ', 'lambda' => 'λ', 'mu' => 'μ',
'nu' => 'ν', 'xi' => 'ξ', 'omicron' => 'ο', 'pi' => 'π',
'rho' => 'ρ', 'sigma' => 'σ', 'tau' => 'τ', 'upsilon' => 'υ',
'phi' => 'φ', 'chi' => 'χ', 'psi' => 'ψ', 'omega' => 'ω',
'Alpha' => 'Α', 'Beta' => 'Β', 'Gamma' => 'Γ', 'Delta' => 'Δ',
'Epsilon' => 'Ε', 'Zeta' => 'Ζ', 'Eta' => 'Η', 'Theta' => 'Θ',
'Iota' => 'Ι', 'Kappa' => 'Κ', 'Lambda' => 'Λ', 'Mu' => 'Μ',
'Nu' => 'Ν', 'Xi' => 'Ξ', 'Omicron' => 'Ο', 'Pi' => 'Π',
'Rho' => 'Ρ', 'Sigma' => 'Σ', 'Tau' => 'Τ', 'Upsilon' => 'Υ',
'Phi' => 'Φ', 'Chi' => 'Χ', 'Psi' => 'Ψ', 'Omega' => 'Ω',
];
// ギリシャ文字を置換
foreach ($greekLetters as $text => $symbol) {
$expression = preg_replace("/\b$text\b/", $symbol, $expression);
}
// 上付き文字(例: x^2
$expression = preg_replace_callback('/(\w+)\^(\{?(\d+|\w+)\}?)/', function($matches) {
$base = $matches[1];
$exponent = $matches[2];
if ($exponent[0] === '{') $exponent = substr($exponent, 1, -1);
$superscripts = str_split($exponent);
$supMap = [
'0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴',
'5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹',
'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ',
'i' => 'ⁱ', 'n' => 'ⁿ',
];
$converted = '';
foreach ($superscripts as $char) $converted .= $supMap[$char] ?? $char;
return $base.'<sup>'.$converted.'</sup>';
}, $expression);
// 下付き文字(例: x_1
$expression = preg_replace_callback('/(\w+)_((\d+|\w+)\}?)/', function($matches) {
$base = $matches[1];
$subscript = $matches[2];
if ($subscript[0] === '{') $subscript = substr($subscript, 1, -1);
$subscripts = str_split($subscript);
$subMap = [
'0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄',
'5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉',
];
$converted = '';
foreach ($subscripts as $char) {
$converted .= $subMap[$char] ?? $char;
}
return $base.'<sub>'.$converted.'</sub>';
}, $expression);
// 分数(例: 4/5x, a/b
$expression = preg_replace('/(\d+|\w+)\/(\d*\w+)/',
'<span class="fraction"><span class="numerator">$1</span><span class="denominator">$2</span></span>',
$expression);
// 演算子
$operators = [
'\*' => '・',
'!=' => '≠',
'>=' => '≥',
'<=' => '≤',
'sqrt' => '√',
];
foreach ($operators as $op => $symbol) $expression = preg_replace("/\b$op\b/", $symbol, $expression);
// 数式全体をspanで囲む
return '<span class="algebraic">'.$expression.'</span>';
}
/**
* リストを作成する
*
* @param array $items リストアイテムの配列
* @param int $maxLevel 最大ネストレベル
* @return string HTMLのリスト
*/
private function createList(array $items, int $maxLevel = 1): string {
if ($items === []) return '';
$html = '';
$currentLevel = 0;
$listStack = [];
$currentType = '';
foreach ($items as $item) {
$level = isset($item['level']) ? $item['level'] : $currentLevel;
while ($currentLevel > $level)
$html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
while ($currentLevel < $level) {
$currentLevel++;
$listStack[] = $item['type'];
$html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
}
if ($currentType != $item['type'] && $currentLevel == $item['level']) {
if (!empty($listStack)) {
$html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
$listStack[] = $item['type'];
$html .= str_repeat(' ', $currentLevel - 1)."<".$item['type'].">\n";
}
}
$currentType = $item['type'];
}
$html .= " <p>\n <{$currentType}>\n";
foreach ($items as $item) {
$html .= str_repeat(' ', $currentLevel)." <li>".$item['content']."</li>\n";
}
$html .= " </{$currentType}>\n </p>";
while (!empty($listStack)) {
$html .= str_repeat(' ', $currentLevel)."</".array_pop($listStack).">\n";
$currentLevel--;
}
return rtrim($html);
}
/**
* コードブロックを作成する
*
* @return string HTMLのコードブロック
*/
private function createCodeBlock(): string {
$code = htmlspecialchars(implode("\n", $this->codeBlockContent));
$class = $this->codeBlockLanguage ? " class=\"language-{$this->codeBlockLanguage}\"" : '';
return " <p>\n <pre><code{$class}>{$code}</code></pre>";
// $raw = implode("\n", $this->codeBlockContent);
// $lang = $this->codeBlockLanguage ?: 'txt';
// $class = $lang === 'txt' ? '' : " class=\"language-{$lang}\"";
// $highlighted = $this->highlightCode($raw, $lang);
// return "<pre><code{$class}>{$highlighted}</code></pre>";
}
private function createBlockquote(array $content): string {
return " <p>\n <blockquote>\n ".implode("<br />\n ", $content)."\n </blockquote>\n </p>";
}
/**
* テーブルを作成する
*
* @param array $headers ヘッダー配列
* @param array $rows 行データの配列
* @return string HTMLのテーブル
*/
private function createTable(array $headers, array $rows): string {
$html = " <p>\n <table>\n";
// ヘッダーを追加
if (!empty($headers)) {
$html .= " <thead>\n <tr>\n";
foreach ($headers as $header) {
$html .= " <th>".$this->parseInline($header)."</th>\n";
}
$html .= " </tr>\n </thead>\n";
}
// 行を追加
if (!empty($rows)) {
$html .= " <tbody>\n";
foreach ($rows as $row) {
$html .= " <tr>\n";
foreach ($row as $cell) {
$html .= " <td>".$this->parseInline($cell)."</td>\n";
}
$html .= " </tr>\n";
}
$html .= " </tbody>\n";
}
$html .= " </table>\n </p>";
return $html;
}
/**
* Pure-PHP syntax highlighter
*
* @param string $code Raw source code
* @param string $lang Language identifier (html, css, js, php, …)
* @return string HTML with <span class="hl-…"> tokens
*/
private function highlightCode(string $code, string $lang): string
{
$lang = strtolower(trim($lang));
$rules = [];
// ---- COMMON -------------------------------------------------------
$rules['comment'] = [
// /* … */ (multi-line)
['/\/\*(?:.|\n)*?\*\//', 'hl-comment'],
// // …\n
['/\/\/.*(?=\n|$)/', 'hl-comment'],
];
$rules['string'] = [
// "…" and '…' (allow escaped quotes)
['/"(?:\\.|[^"\\\n])*"/', 'hl-string'],
["/'(?:\\.|[^'\\\n])*'/", 'hl-string'],
];
$rules['number'] = [
['/\b(?:0x[0-9a-fA-F]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/', 'hl-number'],
];
// ---- HTML ---------------------------------------------------------
if ($lang === 'html') {
$rules['tag'] = [['/&lt;\/?[a-zA-Z0-9\-]+/', 'hl-tag']];
$rules['attr'] = [['/\s[a-zA-Z0-9\-]+(?==)/', 'hl-attr']];
$rules['value'] = [['/(?<==)"(?:\\.|[^"\\\n])*"(?=[>\s])/', 'hl-value']];
}
// ---- CSS ----------------------------------------------------------
if ($lang === 'css') {
$rules['selector'] = [['/^[\t ]*[a-zA-Z0-9#\.\-\:$$ $$\=\*\>\+\~\^]+(?=\s*\{)/m', 'hl-selector']];
$rules['property'] = [['/\b[a-z\-]+(?=\s*:)/', 'hl-property']];
$rules['value'] = [['/(?<=\:)\s*[^;]+(?=;)/', 'hl-value']];
$rules['unit'] = [['/\b\d+(px|em|rem|%|vh|vw|pt|pc|cm|mm|in)\b/i', 'hl-unit']];
}
// ---- PHP ----------------------------------------------------------
if ($lang === 'php') {
$keywords = 'abstract|and|array|as|break|callable|case|catch|class|clone|'
. 'const|continue|declare|default|die|do|echo|else|elseif|empty|'
. 'enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|'
. 'extends|final|finally|for|foreach|function|global|goto|if|'
. 'implements|include|include_once|instanceof|insteadof|interface|'
. 'isset|list|namespace|new|or|print|private|protected|public|'
. 'require|require_once|return|static|switch|throw|trait|try|unset|'
. 'use|var|while|xor|yield';
$rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
$rules['function']= [['/\b([a-zA-Z_\x7f-\xff][\w\x7f-\xff]*)(?=\s*\()/', 'hl-function']];
$rules['variable']= [['/\$[a-zA-Z_\x7f-\xff][\w\x7f-\xff]*/', 'hl-variable']];
}
// ---- Shell --------------------------------------------------------
if ($lang === 'sh') {
$keywords = 'if|then|else|elif|fi|case|esac|for|select|while|until|do|done|in';
$rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
$rules['variable']= [['/\$\{?[a-zA-Z_][\w]*\}?/', 'hl-variable']];
}
// ---- JSON ---------------------------------------------------------
if ($lang === 'json') {
$rules['property'] = [['/"([^"]+)":/', '<span class="hl-property">$1</span>:']];
}
// ---- C / C++ -----------------------------------------------
if (in_array($lang, ['c', 'cpp', 'cs'])) {
$keywords = 'abstract|assert|boolean|break|byte|case|catch|char|class|const|'
. 'continue|default|do|double|else|enum|extends|false|final|finally|'
. 'float|for|goto|if|implements|import|instanceof|int|interface|long|'
. 'native|new|null|package|private|protected|public|return|short|static|'
. 'strictfp|super|switch|synchronized|this|throw|throws|transient|true|'
. 'try|void|volatile|while';
$rules['keyword'] = [['/\b(?:' . $keywords . ')\b/', 'hl-keyword']];
$rules['type'] = [['/\b(?:int|float|double|char|bool|void|string)\b/', 'hl-type']];
$rules['define'] = [['/(#include)|(#define)/', 'hl-define']];
}
if ($lang === 'markdown') {
$rules['header'] = [['/^(#{1,6})\s+(.+)$/', '<span class="hl-header">$1 $2</span>']];
$rules['link'] = [['/\[([^\]]+)\]\(([^)]+) $$/', '<span class="hl-link">[$1]($2)</span>']];
}
$html = htmlspecialchars($code, ENT_NOQUOTES, 'UTF-8');
$order = ['comment', 'string', 'number'];
foreach (['tag','attr','value','selector','property','unit','keyword','function','variable','type','header','link'] as $k) {
if (isset($rules[$k])) $order[] = $k;
}
foreach ($order as $type) {
if (!isset($rules[$type])) continue;
foreach ($rules[$type] as $rule) {
[$pattern, $class] = $rule;
$html = preg_replace_callback(
$pattern,
function ($m) use ($class) {
// For JSON property we already built the <span>
if (strpos($m[0], '<span') === 0) return $m[0];
$inner = $m[0];
// Preserve already escaped entities inside strings/comments
return '<span class="'.$class.'">'.$inner.'</span>';
},
$html
);
}
}
return $html;
}
}