'C', 'cpp' => 'C++', 'cc' => 'C++', 'hpp' => 'C++', 'cs' => 'C#', 'slang' => 'Slang', 'glsl' => 'GLSL', 'hlsl' => 'HLSL', 'msl' => 'MSL', 'wgsl' => 'WGSL', 'spirv' => 'SPIR-V', 'asm' => 'Assembly', 'php' => 'PHP', 'js' => 'JavaScript', 'sh' => 'Shell', 'txt' => 'Text', 'json' => 'JSON', 'go' => 'Go', 'rust' => 'Rust', 'zig' => 'Zig', 'odin' => 'Odin', 'sql' => 'SQL', ]; 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(); } } /** * 文字列から(READMEファイル等、メタデータ無し) * * @param string $text Markdown * @return self */ public static function fromString(string $text): self { $m = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); $m->html = []; $m->path = ''; $m->text = $text; return $m; } /** * メタデータを取得する * * @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 { if ($this->text !== null) { $this->content = trim(str_replace(["\r\n", "\r"], "\n", $this->text)); } else { $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 = self::removeComments(explode("\n", $this->content)); $currentParagraph = []; $inList = false; $listItems = []; $listLevel = 0; $inBlockquote = false; $blockquoteContent = []; $tableHeaders = []; $tableRows = []; $inTable = false; $inMathBlock = false; $mathLines = []; $inTabs = false; $tabItems = []; $codeBlockLabel = ''; $headerCount = 1; $this->mathErrors = []; $i = 0; $flush = function() use (&$currentParagraph, &$inList, &$listItems, &$listLevel, &$inTable, &$tableHeaders, &$tableRows, &$inBlockquote, &$blockquoteContent): void { if ($inList) { $this->html[] = $this->createList($listItems); $listItems = []; $inList = false; $listLevel = 0; } if ($inTable) { $this->html[] = $this->createTable($tableHeaders, $tableRows); $tableHeaders = []; $tableRows = []; $inTable = false; } if ($inBlockquote) { $this->html[] = $this->createBlockquote($blockquoteContent); $blockquoteContent = []; $inBlockquote = false; } if (!empty($currentParagraph)) { $this->html[] = "

\n ".implode("", $currentParagraph)."\n

"; $currentParagraph = []; } }; foreach ($lines as $line) { $i++; $rawLine = rtrim($line, "\r\n"); // コードブロック(```言語 ラベル) if (preg_match('/^```([\w\+\-#\.]*)(?:\s+(.*\S))?\s*$/u', $rawLine, $matches)) { if (!$this->inCodeBlock) { $flush(); $this->inCodeBlock = true; $this->codeBlockLanguage = $matches[1]; $codeBlockLabel = $matches[2] ?? ''; continue; } else { if ($inTabs) { $tabItems[] = [ 'lang' => $this->codeBlockLanguage, 'label' => $codeBlockLabel, 'code' => $this->codeBlockContent, ]; } else { $this->html[] = $this->createCodeBlock(); } $this->inCodeBlock = false; $this->codeBlockLanguage = ''; $this->codeBlockContent = []; $codeBlockLabel = ''; continue; } } if ($this->inCodeBlock) { $this->codeBlockContent[] = $rawLine; continue; } if ($inMathBlock) { if (trim($rawLine) === '$$') { $this->html[] = $this->createDisplayMath(implode("\n", $mathLines)); $mathLines = []; $inMathBlock = false; } else { $mathLines[] = $rawLine; } continue; } if (trim($rawLine) === '$$') { $flush(); $inMathBlock = true; continue; } // 1行の数式ブロック:$$…$$だけの行 / a line holding only $$…$$ is display math if (preg_match('/^\s*\$\$(.+)\$\$\s*$/u', $rawLine, $matches) && substr_count($rawLine, '$$') === 2) { $flush(); $this->html[] = $this->createDisplayMath($matches[1]); continue; } // コードタブ(:::tabs … :::) / code tabs if (preg_match('/^:::\s*tabs\s*$/', trim($rawLine))) { $flush(); $inTabs = true; $tabItems = []; continue; } if ($inTabs) { if (trim($rawLine) === ':::') { $this->html[] = $this->createTabs($tabItems); $tabItems = []; $inTabs = false; } continue; // タブの中ではコードブロック以外は無視 / only code blocks inside tabs } $hasBR = str_ends_with($rawLine, '\\'); $line = rtrim($rawLine, "\\"); // テーブルの処理 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 ($this->inCodeBlock) { $this->html[] = $this->createCodeBlock(); $this->inCodeBlock = false; $this->codeBlockContent = []; } if ($inMathBlock) $this->html[] = $this->createDisplayMath(implode("\n", $mathLines)); if ($inTabs) $this->html[] = $this->createTabs($tabItems); $flush(); return implode("\n", $this->html); } /** * コメントを取り除く(parse()の前処理。プレビューや検索にも使う) * * 「//」で始まる行と、「/*」で始まる行から「*/」までは無い物として扱う。 * 「*/」の後に文字があれば、その部分は残す。閉じられていない「/*」は最後までコメント。 * コードブロックの中はそのまま。 * * @param array $lines 行の配列 * @return array コメントを取り除いた行の配列 */ public static function removeComments(array $lines): array { $out = []; $inCode = false; $inComment = false; foreach ($lines as $line) { $raw = rtrim($line, "\r\n"); if (!$inComment && preg_match('/^```([\w\+\-#\.]*)(?:\s+(.*\S))?\s*$/u', $raw)) { $inCode = !$inCode; $out[] = $line; continue; } if ($inCode) { $out[] = $line; continue; } // 1行に「/* … */ /* … */ 本文」の様に続く場合もあるので、繰り返す $rest = $raw; while (true) { if ($inComment) { $end = strpos($rest, '*/'); if ($end === false) { $rest = null; break; } $inComment = false; $rest = ltrim(substr($rest, $end + 2)); if ($rest === '') { $rest = null; break; } continue; } $trimmed = ltrim($rest); if (str_starts_with($trimmed, '//')) { $rest = null; } else if (str_starts_with($trimmed, '/*')) { $inComment = true; $rest = substr($trimmed, 2); continue; } break; } if ($rest !== null) $out[] = $rest === $raw ? $line : $rest; } return $out; } /** * テキストからコメントを取り除く(removeComments()の文字列版) * * @param string $text Markdownのテキスト * @return string */ public static function stripComments(string $text): string { return implode("\n", self::removeComments(explode("\n", str_replace("\r\n", "\n", $text)))); } /** * 直前のparse()で見つかった、変換出来なかったLaTeXコマンド * * @return array エラーの配列 */ public function getMathErrors(): array { return $this->mathErrors; } // 機能性メソッド /** * インラインのMarkdown記法をパースする * * @param string $text パースするテキスト * @return string HTMLとしてレンダリングされたテキスト */ private function parseInline(string $text): string { $this->algebraicPlaceholder = []; $this->codePlaceholder = []; $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); // インラインコードは他の記法より先に保護する(中の*や_や\をそのまま表示する為) $text = preg_replace_callback('/(?codePlaceholder).'CODE}}'; // 記号の前の\だけをエスケープとして消す(`json\_encode`や`C:\\`の為)。\nや\0はそのまま。 $code = preg_replace('/\\\\([\\\\`*_~$%^!\[\]()#{}|.+\-])/u', '$1', $matches[1]); $this->codePlaceholder[$placeholder] = ''.$code.''; return $placeholder; }, $text); $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)', ]; foreach ($patterns as $pattern => $replacement) { if (is_callable($replacement)) { $text = preg_replace_callback($pattern, $replacement, $text); } else { // kys([$pattern, $replacement, $text]); $result = preg_replace($pattern, $replacement, $text); // kys($result); if ($result === null) continue; // kys($text); $text = $result; } } // エスケープ文字の\を消す(数式とコードを戻す前に) $text = preg_replace('/\\\\(.)/u', '$1', $text); // プレースホルダーを数式とコードに交換 foreach ($this->algebraicPlaceholder as $placeholder => $expr) { $text = str_replace($placeholder, $this->parseAlgebraic($expr), $text); } foreach ($this->codePlaceholder as $placeholder => $code) { $text = str_replace($placeholder, $code, $text); } return $text; } /** * 数式記法をパースする * * @param string $expression パースする数式 * @param bool $display ブロック表示ならtrue * @return string MathMLとしてレンダリングされた数式 */ private function parseAlgebraic(string $expression, bool $display = false): string { // HTMLエスケープを戻す(Latexクラスが自分でエスケープする) $expression = htmlspecialchars_decode($expression, ENT_QUOTES); if ($this->latex === null) $this->latex = new Latex(); $mathml = $this->latex->render($expression, $display); foreach ($this->latex->getErrors() as $err) $this->mathErrors[] = $err; return $mathml; } /** * ブロック表示の数式を作成する * * @param string $expression LaTeXの数式 * @return string HTMLの数式ブロック */ private function createDisplayMath(string $expression): string { return "
".$this->parseAlgebraic($expression, true)."
"; } /** * コードタブを作成する(JavaScript無し、ラジオボタンとCSSだけ) * * @param array $items ['lang' => 言語, 'label' => ラベル, 'code' => 行の配列]の配列 * @return string HTMLのタブ */ private function createTabs(array $items): string { if (empty($items)) return ''; $group = 'code-tabs-'.(++self::$tabGroupCount); $inputs = ''; $panels = ''; foreach ($items as $n => $item) { $id = "{$group}-{$n}"; $label = $item['label'] !== '' ? $item['label'] : (self::LANG_LABELS[strtolower($item['lang'])] ?? ($item['lang'] !== '' ? $item['lang'] : 'Code')); $checked = $n === 0 ? ' checked="checked"' : ''; [$code, $class] = $this->renderCode(implode("\n", $item['code']), $item['lang']); $inputs .= " \n"; $inputs .= " \n"; $panels .= "
{$code}
\n"; } return "
\n{$inputs}{$panels}
"; } /** * リストを作成する * * @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"; $currentLevel--; } 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, $class] = $this->renderCode(implode("\n", $this->codeBlockContent), $this->codeBlockLanguage); return "

    \n

    {$code}
    "; } /** * コードをHTMLにする(対応している言語はシンタックスハイライトする) * * @param string $raw 生のコード * @param string $lang 言語名(```の後) * @return array [エスケープ済みのコード, class属性] */ private function renderCode(string $raw, string $lang): array { $lang = strtolower($lang); $attr = htmlspecialchars($lang, ENT_QUOTES, 'UTF-8'); // Syntax::colorize()はエスケープ済みのHTMLを返す if ($lang !== '' && Syntax::isSupported($lang)) { return [(new Syntax($lang))->colorize($raw), " class=\"language-{$attr} hl\""]; } return [htmlspecialchars($raw), $lang !== '' ? " class=\"language-{$attr}\"" : '']; } 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; } }