複数ネームスペースを可能に

This commit is contained in:
2026-02-27 03:15:17 +09:00
parent b723230818
commit a1bd29cff6
32 changed files with 95 additions and 73 deletions

648
src/Std/Lib/Markdown.php Normal file
View File

@@ -0,0 +1,648 @@
<?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);
}
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;
}
}