記事でのコメント
守矢諏訪子 <suwako@076.moe>、2026-11-10 20:37
コミット e6dd74f6e4fea8a8021082dda829229f6c0e8185
・親 64fb903
・ファイルを見る
6個のファイル、+193 -27
| @@ -7,6 +7,9 @@ | ||
| 7 | 7 | category: test,おちんちん,でっかいおっぱい |
| 8 | 8 | css: code,blockquote,table,blink |
| 9 | 9 | ---- |
| 10 | +//ニッガー!!\ | |
| 11 | +/*sasa | |
| 12 | +re*/ | |
| 10 | 13 | # ケロケロ |
| 11 | 14 | ```c |
| 12 | 15 | int main(int argc, char **argv) { |
| @@ -37,6 +37,7 @@ | ||
| 37 | 37 | */ |
| 38 | 38 | namespace Site\Controller; |
| 39 | 39 | |
| 40 | +use Std\Lib\Markdown; | |
| 40 | 41 | use Roles; |
| 41 | 42 | |
| 42 | 43 | class BlogPost { |
| @@ -81,7 +82,8 @@ | ||
| 81 | 82 | } |
| 82 | 83 | } |
| 83 | 84 | |
| 84 | - $articleBody = trim($parts[1]); | |
| 85 | + // コメントはプレビューに出さない | |
| 86 | + $articleBody = trim(Markdown::stripComments($parts[1])); | |
| 85 | 87 | $preview = mb_substr(strip_tags($articleBody), 0, 50).'...'; |
| 86 | 88 | $slug = basename($file, '.md'); |
| 87 | 89 | if (isset($metadata['draft']) && !$isStaff) continue; |
| @@ -250,7 +250,8 @@ | ||
| 250 | 250 | $content = file_get_contents($filePath); |
| 251 | 251 | $parts = explode('----', $content, 2); |
| 252 | 252 | if (count($parts) > 1) { |
| 253 | - $articleBody = trim($parts[1]); | |
| 253 | + // コメントは検索しない(非公開のメモが検索で当たらない様に) | |
| 254 | + $articleBody = trim(Markdown::stripComments($parts[1])); | |
| 254 | 255 | |
| 255 | 256 | foreach ($keywords as $keyword) { |
| 256 | 257 | $keyword = trim($keyword); |
| @@ -117,7 +117,7 @@ | ||
| 117 | 117 | $this->content = count($parts) > 1 ? trim($parts[1]): trim($content); |
| 118 | 118 | $this->html = []; |
| 119 | 119 | |
| 120 | - $lines = explode("\n", $this->content); | |
| 120 | + $lines = self::removeComments(explode("\n", $this->content)); | |
| 121 | 121 | $currentParagraph = []; |
| 122 | 122 | |
| 123 | 123 | $inList = false; |
| @@ -167,14 +167,6 @@ | ||
| 167 | 167 | $i++; |
| 168 | 168 | $rawLine = rtrim($line, "\r\n"); |
| 169 | 169 | |
| 170 | - // コメント | |
| 171 | - // if (str_starts_with($line, '//')) { | |
| 172 | - // if ($hasBR && !empty($currentParagraph)) { | |
| 173 | - // $currentParagraph[] = ''; | |
| 174 | - // } | |
| 175 | - // continue; | |
| 176 | - // } | |
| 177 | - | |
| 178 | 170 | // コードブロック(```言語 ラベル) |
| 179 | 171 | if (preg_match('/^```([\w\+\-#\.]*)(?:\s+(.*\S))?\s*$/u', $rawLine, $matches)) { |
| 180 | 172 | if (!$this->inCodeBlock) { |
| @@ -394,6 +386,79 @@ | ||
| 394 | 386 | $flush(); |
| 395 | 387 | |
| 396 | 388 | return implode("\n", $this->html); |
| 389 | + } | |
| 390 | + | |
| 391 | + /** | |
| 392 | + * コメントを取り除く(parse()の前処理。プレビューや検索にも使う) | |
| 393 | + * | |
| 394 | + * 「//」で始まる行と、「/*」で始まる行から「*/」までは無い物として扱う。 | |
| 395 | + * 「*/」の後に文字があれば、その部分は残す。閉じられていない「/*」は最後までコメント。 | |
| 396 | + * コードブロックの中はそのまま。 | |
| 397 | + * | |
| 398 | + * @param array $lines 行の配列 | |
| 399 | + * @return array コメントを取り除いた行の配列 | |
| 400 | + */ | |
| 401 | + public static function removeComments(array $lines): array { | |
| 402 | + $out = []; | |
| 403 | + $inCode = false; | |
| 404 | + $inComment = false; | |
| 405 | + | |
| 406 | + foreach ($lines as $line) { | |
| 407 | + $raw = rtrim($line, "\r\n"); | |
| 408 | + | |
| 409 | + if (!$inComment && preg_match('/^```([\w\+\-#\.]*)(?:\s+(.*\S))?\s*$/u', $raw)) { | |
| 410 | + $inCode = !$inCode; | |
| 411 | + $out[] = $line; | |
| 412 | + continue; | |
| 413 | + } | |
| 414 | + if ($inCode) { | |
| 415 | + $out[] = $line; | |
| 416 | + continue; | |
| 417 | + } | |
| 418 | + | |
| 419 | + // 1行に「/* … */ /* … */ 本文」の様に続く場合もあるので、繰り返す | |
| 420 | + $rest = $raw; | |
| 421 | + while (true) { | |
| 422 | + if ($inComment) { | |
| 423 | + $end = strpos($rest, '*/'); | |
| 424 | + if ($end === false) { | |
| 425 | + $rest = null; | |
| 426 | + break; | |
| 427 | + } | |
| 428 | + $inComment = false; | |
| 429 | + $rest = ltrim(substr($rest, $end + 2)); | |
| 430 | + if ($rest === '') { | |
| 431 | + $rest = null; | |
| 432 | + break; | |
| 433 | + } | |
| 434 | + continue; | |
| 435 | + } | |
| 436 | + | |
| 437 | + $trimmed = ltrim($rest); | |
| 438 | + if (str_starts_with($trimmed, '//')) { | |
| 439 | + $rest = null; | |
| 440 | + } else if (str_starts_with($trimmed, '/*')) { | |
| 441 | + $inComment = true; | |
| 442 | + $rest = substr($trimmed, 2); | |
| 443 | + continue; | |
| 444 | + } | |
| 445 | + break; | |
| 446 | + } | |
| 447 | + | |
| 448 | + if ($rest !== null) $out[] = $rest === $raw ? $line : $rest; | |
| 449 | + } | |
| 450 | + | |
| 451 | + return $out; | |
| 452 | + } | |
| 453 | + | |
| 454 | + /** | |
| 455 | + * テキストからコメントを取り除く(removeComments()の文字列版) | |
| 456 | + * | |
| 457 | + * @param string $text Markdownのテキスト | |
| 458 | + * @return string | |
| 459 | + */ | |
| 460 | + public static function stripComments(string $text): string { | |
| 461 | + return implode("\n", self::removeComments(explode("\n", str_replace("\r\n", "\n", $text)))); | |
| 397 | 462 | } |
| 398 | 463 | |
| 399 | 464 | /** |
| @@ -44,14 +44,17 @@ | ||
| 44 | 44 | private array $blocks = []; |
| 45 | 45 | private array $custFunc = []; |
| 46 | 46 | private array $custCss = []; |
| 47 | + private string $viewDir; | |
| 47 | 48 | |
| 48 | 49 | /** |
| 49 | 50 | * テンプレートクラスのコンストラクタ |
| 50 | 51 | * |
| 51 | 52 | * @param string $tmplPath テンプレートのパス |
| 53 | + * @param string|null $viewDir テンプレートのディレクトリ(既定はROOT/view、テスト用) | |
| 52 | 54 | * @return void |
| 53 | 55 | */ |
| 54 | - public function __construct(string $tmplPath) { | |
| 56 | + public function __construct(string $tmplPath, ?string $viewDir = null) { | |
| 57 | + $this->viewDir = rtrim($viewDir ?? ROOT.'/view', '/'); | |
| 55 | 58 | $this->tmplPath = rtrim($tmplPath, '/'); |
| 56 | 59 | |
| 57 | 60 | if (substr($this->tmplPath, 0, 1) !== '/') { |
| @@ -112,24 +115,25 @@ | ||
| 112 | 115 | * @return void |
| 113 | 116 | */ |
| 114 | 117 | public function render(string $tmplName): void { |
| 115 | - $tmplPath = ROOT.'/view'.$this->tmplPath.'/'.$tmplName.$this->tmplExt; | |
| 118 | + $tmplPath = $this->viewDir.$this->tmplPath.'/'.$tmplName.$this->tmplExt; | |
| 116 | 119 | if (!file_exists($tmplPath)) |
| 117 | 120 | throw new \RuntimeException("テンプレートファイルを見つけません:{$tmplPath}"); |
| 118 | 121 | |
| 119 | - extract($this->vars); | |
| 120 | - | |
| 121 | 122 | $content = file_get_contents($tmplPath); |
| 122 | 123 | |
| 123 | - // インクルードディレクティブを処理 | |
| 124 | + // インクルードディレクティブを処理(無限ループにならない様に回数を制限する) | |
| 125 | + $depth = 0; | |
| 124 | 126 | while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) { |
| 127 | + if (++$depth > 32) throw new \RuntimeException("インクルードが深過ぎます(循環?):{$tmplPath}"); | |
| 125 | 128 | $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string { |
| 126 | - $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").$this->tmplExt; | |
| 129 | + $inclPath = $this->viewDir.'/'.trim($m[1], "'\" ").$this->tmplExt; | |
| 127 | 130 | if (!file_exists($inclPath)) |
| 128 | 131 | throw new \RuntimeException("ファイルを見つけません: {$inclPath}"); |
| 129 | 132 | return file_get_contents($inclPath); |
| 130 | 133 | }, $content); |
| 131 | 134 | } |
| 132 | 135 | |
| 136 | + $content = $this->procComments($content); | |
| 133 | 137 | $content = $this->procDirs($content); |
| 134 | 138 | $content = $this->procVars($content); |
| 135 | 139 | $content = $this->procFuncs($content); |
| @@ -137,13 +141,48 @@ | ||
| 137 | 141 | $tmpFile = tempnam(sys_get_temp_dir(), 'tmpl_'); |
| 138 | 142 | file_put_contents($tmpFile, $content); |
| 139 | 143 | |
| 140 | - include $tmpFile; | |
| 141 | - unlink($tmpFile); | |
| 144 | + try { | |
| 145 | + // 別のスコープで実行する。以前はextract()がこのメソッドの$contentや$tmplPathを上書き出来た | |
| 146 | + // (「content」という名前の変数を割り当てると、テンプレートのソースが表示された) | |
| 147 | + (function() { | |
| 148 | + extract(func_get_arg(1)); | |
| 149 | + include func_get_arg(0); | |
| 150 | + })($tmpFile, $this->vars); | |
| 151 | + } finally { | |
| 152 | + unlink($tmpFile); | |
| 153 | + } | |
| 142 | 154 | } |
| 143 | 155 | |
| 156 | + /** | |
| 157 | + * テンプレートをレンダリングして、文字列として返す(メール本文等に) | |
| 158 | + * | |
| 159 | + * @param string $tmplName テンプレート名 | |
| 160 | + * @return string レンダリングされた内容 | |
| 161 | + */ | |
| 162 | + public function fetch(string $tmplName): string { | |
| 163 | + ob_start(); | |
| 164 | + try { | |
| 165 | + $this->render($tmplName); | |
| 166 | + return (string)ob_get_contents(); | |
| 167 | + } finally { | |
| 168 | + ob_end_clean(); | |
| 169 | + } | |
| 170 | + } | |
| 171 | + | |
| 144 | 172 | // 機能性メソッド |
| 145 | 173 | |
| 146 | 174 | /** |
| 175 | + * コメント({# … #})を取り除く | |
| 176 | + * 他の構文より先に処理するので、コメントの中の{{ }}等は実行されない | |
| 177 | + * | |
| 178 | + * @param string $content テンプレート内容 | |
| 179 | + * @return string 処理後の内容 | |
| 180 | + */ | |
| 181 | + private function procComments(string $content): string { | |
| 182 | + return preg_replace('/\{#.*?#\}/s', '', $content); | |
| 183 | + } | |
| 184 | + | |
| 185 | + /** | |
| 147 | 186 | * テンプレートディレクティブを処理する |
| 148 | 187 | * |
| 149 | 188 | * @param string $content テンプレート内容 |
| @@ -153,7 +192,7 @@ | ||
| 153 | 192 | // includeディレクティブの処理 |
| 154 | 193 | while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) { |
| 155 | 194 | $content = preg_replace_callback('/\{@\s*include\((.*?)\)\s*@\}/s', function($m): bool|string { |
| 156 | - $inclPath = ROOT.'/view/'.trim($m[1], "'\" ").'.php'; | |
| 195 | + $inclPath = $this->viewDir.'/'.trim($m[1], "'\" ").'.php'; | |
| 157 | 196 | if (!file_exists($inclPath)) |
| 158 | 197 | throw new \RuntimeException("ファイルを見つけません: {$inclPath}"); |
| 159 | 198 | return file_get_contents($inclPath); |
| @@ -228,7 +267,7 @@ | ||
| 228 | 267 | */ |
| 229 | 268 | private function procVars(string $content): string { |
| 230 | 269 | // 変数の出力(エスケープ処理なし) |
| 231 | - $content = preg_replace_callback('/\{\{\{s*(.*?)\s*\}\}\}/', function($m): string { | |
| 270 | + $content = preg_replace_callback('/\{\{\{\s*(.*?)\s*\}\}\}/', function($m): string { | |
| 232 | 271 | return '<?= '.trim($m[1]).' ?>'; |
| 233 | 272 | }, $content); |
| 234 | 273 | |
| @@ -243,11 +282,6 @@ | ||
| 243 | 282 | if (count($parts) !== 2) |
| 244 | 283 | throw new \RuntimeException("不正な値の形式"); |
| 245 | 284 | return '<?php '.trim($parts[0]).' = '.trim($parts[1]).'; ?>'; |
| 246 | - }, $content); | |
| 247 | - | |
| 248 | - // コメント | |
| 249 | - $content = preg_replace_callback('/\{#\s*(.*?)\s*#\}/', function($m): string { | |
| 250 | - return '<?php /*'.trim($m[1]).'*/ ?>'; | |
| 251 | 285 | }, $content); |
| 252 | 286 | |
| 253 | 287 | // PHPコードの実行 |
| @@ -196,7 +196,68 @@ | ||
| 196 | 196 | $test->assertEquals($expect, $actual); |
| 197 | 197 | }); |
| 198 | 198 | |
| 199 | - $test->skip('コメントはHTMLに交換出来るはず', '作成中・・・'); | |
| 199 | + $test->it('「//」で始まる行は無視されるはず', function($test): void { | |
| 200 | + mkfile("前の行\n// 秘密のメモ\n // 字下げしたメモ\n後の行"); | |
| 201 | + | |
| 202 | + $body = (new Markdown('dietrannynigger', '/blog/'))->parse(); | |
| 203 | + | |
| 204 | + $test->assertFalse(str_contains($body, '秘密のメモ')); | |
| 205 | + $test->assertFalse(str_contains($body, '字下げしたメモ')); | |
| 206 | + // 無い物として扱うので、段落は分かれない | |
| 207 | + $test->assertEquals(1, substr_count($body, '<p>')); | |
| 208 | + $test->assertStringContains('前の行後の行', $body); | |
| 209 | + }); | |
| 210 | + | |
| 211 | + $test->it('「/*」から「*/」までは無視されるはず', function($test): void { | |
| 212 | + mkfile("# 見出し\n/* 1行目\n\n# コメントの中の見出し\n最後の行 */\n本文\n/* 1行だけ */\n/* 後ろに文字 */ 残る\n\n- リスト"); | |
| 213 | + | |
| 214 | + $body = (new Markdown('dietrannynigger', '/blog/'))->parse(); | |
| 215 | + | |
| 216 | + $test->assertFalse(str_contains($body, '1行目')); | |
| 217 | + $test->assertFalse(str_contains($body, 'コメントの中の見出し')); | |
| 218 | + $test->assertFalse(str_contains($body, '最後の行')); | |
| 219 | + $test->assertFalse(str_contains($body, '1行だけ')); | |
| 220 | + $test->assertFalse(str_contains($body, '後ろに文字')); | |
| 221 | + $test->assertStringContains('本文', $body); | |
| 222 | + $test->assertStringContains('残る', $body); | |
| 223 | + $test->assertStringContains('<li>リスト</li>', $body); | |
| 224 | + $test->assertEquals(1, substr_count($body, '<h1')); | |
| 225 | + }); | |
| 226 | + | |
| 227 | + $test->it('閉じていない「/*」は最後までコメントのはず', function($test): void { | |
| 228 | + mkfile("見える\n/* ここから\n全部\n```c\nint x;\n```"); | |
| 229 | + | |
| 230 | + $body = (new Markdown('dietrannynigger', '/blog/'))->parse(); | |
| 231 | + | |
| 232 | + $test->assertStringContains('見える', $body); | |
| 233 | + $test->assertFalse(str_contains($body, '全部')); | |
| 234 | + $test->assertFalse(str_contains($body, '<pre>')); | |
| 235 | + }); | |
| 236 | + | |
| 237 | + $test->it('コードブロックの中のコメントは表示されるはず', function($test): void { | |
| 238 | + mkfile("```c\n// Cのコメント\n/* ブロック */\nint x;\n```\n\n```txt\n/* 開いたまま\n```\n\n後の段落"); | |
| 239 | + | |
| 240 | + $body = (new Markdown('dietrannynigger', '/blog/'))->parse(); | |
| 241 | + | |
| 242 | + $test->assertStringContains('<span class="hl-comment">// Cのコメント</span>', $body); | |
| 243 | + $test->assertStringContains('<span class="hl-comment">/* ブロック */</span>', $body); | |
| 244 | + $test->assertStringContains('/* 開いたまま', $body); | |
| 245 | + // コードブロックの中の「/*」はコメントを始めない | |
| 246 | + $test->assertStringContains('後の段落', $body); | |
| 247 | + }); | |
| 248 | + | |
| 249 | + $test->it('コメントはプレビュー用にも取り除けるはず', function($test): void { | |
| 250 | + $test->assertEquals("a\nb\n```\n// c\n```", Markdown::stripComments("a\r\n// x\r\n/* y\r\nz */\r\nb\r\n```\r\n// c\r\n```")); | |
| 251 | + }); | |
| 252 | + | |
| 253 | + $test->it('URLの「//」や行の途中の「/*」はコメントではないはず', function($test): void { | |
| 254 | + mkfile("https://076.moe/ の // と /* は行の途中\n次の行"); | |
| 255 | + | |
| 256 | + $body = (new Markdown('dietrannynigger', '/blog/'))->parse(); | |
| 257 | + | |
| 258 | + $test->assertStringContains('https://076.moe/ の // と /* は行の途中', $body); | |
| 259 | + $test->assertStringContains('次の行', $body); | |
| 260 | + }); | |
| 200 | 261 | |
| 201 | 262 | $test->it('画像、音楽、動画はHTMLに交換出来るはず', function($test): void { |
| 202 | 263 | mkfile("\n |