Files
LittleBeast/src/Std/Lib/Markdown.php

650 lines
22 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
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;
}
}