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[] = "
\n ".implode("", $currentParagraph)."\n
"; $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[] = "\n ".implode("", $currentParagraph)."\n
"; $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[] = "\n ".implode("", $currentParagraph)."\n
"; $currentParagraph = []; } $this->html[] = "\n ".implode("", $currentParagraph)."\n
"; $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[] = "\n ".implode("", $currentParagraph)."\n
"; $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[] = "\n ".implode("", $currentParagraph)."\n
"; $currentParagraph = []; } $level = strlen($m[1]); $this->html[] = "\n ".implode("", $currentParagraph)."\n
"; $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[] = "\n ".implode("", $currentParagraph)."\n
"; $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[] = "\n ".implode("", $currentParagraph)."\n
"; 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 = [ // 数式 '/(? function($matches): string { $placeholder = "{{ALG".count($this->algebraicPlaceholder).'ALG}}'; $this->algebraicPlaceholder[$placeholder] = $matches[1]; return $placeholder; }, // 太字 '/(? '$1', // 斜体 '/(? '$1', // 下線 '/(? '$1', // 取り消し線 '/(? '$1',
];
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.''.$converted.'';
}, $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.''.$converted.'';
}, $expression);
// 分数(例: 4/5x, a/b)
$expression = preg_replace('/(\d+|\w+)\/(\d*\w+)/',
'$1$2',
$expression);
// 演算子
$operators = [
'\*' => '・',
'!=' => '≠',
'>=' => '≥',
'<=' => '≤',
'sqrt' => '√',
];
foreach ($operators as $op => $symbol) $expression = preg_replace("/\b$op\b/", $symbol, $expression);
// 数式全体をspanで囲む
return ''.$expression.'';
}
/**
* リストを作成する
*
* @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 .= " \n <{$currentType}>\n"; foreach ($items as $item) { $html .= str_repeat(' ', $currentLevel)."
\n
{$code}";
// $raw = implode("\n", $this->codeBlockContent);
// $lang = $this->codeBlockLanguage ?: 'txt';
// $class = $lang === 'txt' ? '' : " class=\"language-{$lang}\"";
// $highlighted = $this->highlightCode($raw, $lang);
// return "{$highlighted}";
}
private function createBlockquote(array $content): string {
return " \n
\n ".implode("\n "; } /** * テーブルを作成する * * @param array $headers ヘッダー配列 * @param array $rows 行データの配列 * @return string HTMLのテーブル */ private function createTable(array $headers, array $rows): string { $html = "
\n ", $content)."\n
\n
| ".$this->parseInline($header)." | \n"; } $html .= "
|---|
| ".$this->parseInline($cell)." | \n"; } $html .= "