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"; continue; } // 引用ブロックの処理 if (preg_match('/^>\s(.+)/', $line, $matches)) { if (!empty($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[] = " ".$this->parseInline($m[2]).""; $headerCount++; continue; } // 箇条書きリスト if (preg_match('/^(\s*)([\*\-])\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[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 "; } } 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[] = "

\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', // Blink (with speed) '/(? '$2', // Blink '/(? '$1', // フォントの大きさ '/(? '$2', // フォントカラー '/(? '$2', // 画像 '/(? '$1', // 音楽 '/(? '', // 動画 '/(? '', // リンク '/(? '$1', // 振り仮名 '/(? '$1($2)', // インラインコード '/(? '$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)."\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)."\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)."

  • ".$item['content']."
  • \n"; } $html .= " \n

    "; while (!empty($listStack)) { $html .= str_repeat(' ', $currentLevel)."\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 "

    \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 ", $content)."\n
    \n

    "; } /** * テーブルを作成する * * @param array $headers ヘッダー配列 * @param array $rows 行データの配列 * @return string HTMLのテーブル */ private function createTable(array $headers, array $rows): string { $html = "

    \n \n"; // ヘッダーを追加 if (!empty($headers)) { $html .= " \n \n"; foreach ($headers as $header) { $html .= " \n"; } $html .= " \n \n"; } // 行を追加 if (!empty($rows)) { $html .= " \n"; foreach ($rows as $row) { $html .= " \n"; foreach ($row as $cell) { $html .= " \n"; } $html .= " \n"; } $html .= " \n"; } $html .= "
    ".$this->parseInline($header)."
    ".$this->parseInline($cell)."
    \n

    "; 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 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'] = [['/<\/?[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'] = [['/"([^"]+)":/', '$1:']]; } // ---- 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+(.+)$/', '$1 $2']]; $rules['link'] = [['/\[([^\]]+)\]\(([^)]+) $$/', '[$1]($2)']]; } $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 if (strpos($m[0], ''.$inner.''; }, $html ); } } return $html; } }