<?php
namespace Site\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 = "----";
public function __construct(string $path, ?string $lang = null) {
$this->html = [];
if ($lang) $this->path = ROOT.'/blog/'.$lang.'/'.$path.'.md';
else $this->path = ROOT.'/blog/'.$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;
foreach ($lines as $line) {
$hasBR = substr($line, -1) === '\\';
$line = rtrim($line, " \t\r\n\\");
// コードブロック
if (preg_match('/^```(\w*)$/', $line, $matches)) {
if (!$this->inCodeBlock) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</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>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$cells = array_map('trim', explode('|', trim($line, '|')));
if (!$inTable) {
$tableHeaders = $cells;
$inTable = true;
} elseif (preg_match('/^\|(\s*:?-+:?\s*\|)+$/', $line)) {
// Skip separator 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>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$this->html[] = "<hr>";
continue;
}
// 引用ブロックの処理
if (preg_match('/^>\s(.+)/', $line, $matches)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</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>".implode("", $currentParagraph)."</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>".implode("", $currentParagraph)."</p>";
$currentParagraph = [];
}
$level = strlen($m[1]);
$this->html[] = "<h{$level}>".$this->parseInline($m[2])."</h{$level}>";
continue;
}
// 箇条書きリスト
if (preg_match('/^(\s*)([\*\-])\s(.+)/', $line, $m)) {
if (!empty($currentParagraph)) {
$this->html[] = " <p>".implode("", $currentParagraph)."</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>".implode("", $currentParagraph)."</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 />";
}
}
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>".implode("", $currentParagraph)."</p>";
return implode("\n", $this->html);
}
// 機能性メソッド
/**
* インラインのMarkdown記法をパースする
*
* @param string $text パースするテキスト
* @return string HTMLとしてレンダリングされたテキスト
*/
private function parseInline(string $text): string {
// 太字
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
// 斜体
$text = preg_replace('/\*(.+?)\*/', '<em>$1</em>', $text);
// 下線
$text = preg_replace('/\_(.+?)\_/', '<u>$1</u>', $text);
// 取り消し線
$text = preg_replace('/\~(.+?)\~/', '<s>$1</s>', $text);
// 画像
$text = preg_replace('/\!\[(.*?)\]\((.+?)\)/', '<img style="width: 100%;" src="$2" alt="$1" />', $text);
// 音楽
$text = preg_replace('/\$\[([^\]]+)\]\(([^\)]+)\)/',
'<audio controls><source src="$2" type="$1" /></audio>', $text);
// 動画
$text = preg_replace('/\#\[([^\]]+)\]\(([^\)]+)\)/',
'<video controls><source src="$2" type="$1" /></video>', $text);
// リンク
$text = preg_replace('/\[(.+?)\]\((.+?)\)/', '<a href="$2">$1</a>', $text);
// 振り仮名
$text = preg_replace('/\<(.+?)\>\((.+?)\)/', '<ruby>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>', $text);
// インラインコード
$text = preg_replace('/`(.+?)`/', '<code>$1</code>', $text);
return $text;
}
/**
* リストを作成する
*
* @param array $items リストアイテムの配列
* @param int $maxLevel 最大ネストレベル
* @return string HTMLのリスト
*/
private function createList(array $items, int $maxLevel = 1): string {
if (empty($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 .= str_repeat(' ', $currentLevel)."<li>".$item['content']."</li>\n";
}
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 "<pre><code{$class}>{$code}</code></pre>";
}
private function createBlockquote(array $content): string {
return "<blockquote>\n <p>" . implode("</p>\n <p>", $content) . "</p>\n</blockquote>";
}
/**
* テーブルを作成する
*
* @param array $headers ヘッダー配列
* @param array $rows 行データの配列
* @return string HTMLのテーブル
*/
private function createTable(array $headers, array $rows): string {
$html = "<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>";
return $html;
}
}