記事でのコメント

守矢諏訪子 <suwako@076.moe>、2026-11-10 20:37
コミット e6dd74f6e4fea8a8021082dda829229f6c0e8185 ・親 64fb903 ・ファイルを見る

6個のファイル、+193 -27

@@ -7,6 +7,9 @@
77 category: test,おちんちん,でっかいおっぱい
88 css: code,blockquote,table,blink
99 ----
10+//ニッガー!!\
11+/*sasa
12+re*/
1013 # ケロケロ
1114 ```c
1215 int main(int argc, char **argv) {
@@ -37,6 +37,7 @@
3737 */
3838 namespace Site\Controller;
3939
40+use Std\Lib\Markdown;
4041 use Roles;
4142
4243 class BlogPost {
@@ -81,7 +82,8 @@
8182 }
8283 }
8384
84- $articleBody = trim($parts[1]);
85+ // コメントはプレビューに出さない
86+ $articleBody = trim(Markdown::stripComments($parts[1]));
8587 $preview = mb_substr(strip_tags($articleBody), 0, 50).'...';
8688 $slug = basename($file, '.md');
8789 if (isset($metadata['draft']) && !$isStaff) continue;
@@ -250,7 +250,8 @@
250250 $content = file_get_contents($filePath);
251251 $parts = explode('----', $content, 2);
252252 if (count($parts) > 1) {
253- $articleBody = trim($parts[1]);
253+ // コメントは検索しない(非公開のメモが検索で当たらない様に)
254+ $articleBody = trim(Markdown::stripComments($parts[1]));
254255
255256 foreach ($keywords as $keyword) {
256257 $keyword = trim($keyword);
@@ -117,7 +117,7 @@
117117 $this->content = count($parts) > 1 ? trim($parts[1]): trim($content);
118118 $this->html = [];
119119
120- $lines = explode("\n", $this->content);
120+ $lines = self::removeComments(explode("\n", $this->content));
121121 $currentParagraph = [];
122122
123123 $inList = false;
@@ -167,14 +167,6 @@
167167 $i++;
168168 $rawLine = rtrim($line, "\r\n");
169169
170- // コメント
171- // if (str_starts_with($line, '//')) {
172- // if ($hasBR && !empty($currentParagraph)) {
173- // $currentParagraph[] = '';
174- // }
175- // continue;
176- // }
177-
178170 // コードブロック(```言語 ラベル)
179171 if (preg_match('/^```([\w\+\-#\.]*)(?:\s+(.*\S))?\s*$/u', $rawLine, $matches)) {
180172 if (!$this->inCodeBlock) {
@@ -394,6 +386,79 @@
394386 $flush();
395387
396388 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))));
397462 }
398463
399464 /**
@@ -44,14 +44,17 @@
4444 private array $blocks = [];
4545 private array $custFunc = [];
4646 private array $custCss = [];
47+ private string $viewDir;
4748
4849 /**
4950 * テンプレートクラスのコンストラクタ
5051 *
5152 * @param string $tmplPath テンプレートのパス
53+ * @param string|null $viewDir テンプレートのディレクトリ(既定はROOT/view、テスト用)
5254 * @return void
5355 */
54- public function __construct(string $tmplPath) {
56+ public function __construct(string $tmplPath, ?string $viewDir = null) {
57+ $this->viewDir = rtrim($viewDir ?? ROOT.'/view', '/');
5558 $this->tmplPath = rtrim($tmplPath, '/');
5659
5760 if (substr($this->tmplPath, 0, 1) !== '/') {
@@ -112,24 +115,25 @@
112115 * @return void
113116 */
114117 public function render(string $tmplName): void {
115- $tmplPath = ROOT.'/view'.$this->tmplPath.'/'.$tmplName.$this->tmplExt;
118+ $tmplPath = $this->viewDir.$this->tmplPath.'/'.$tmplName.$this->tmplExt;
116119 if (!file_exists($tmplPath))
117120 throw new \RuntimeException("テンプレートファイルを見つけません:{$tmplPath}");
118121
119- extract($this->vars);
120-
121122 $content = file_get_contents($tmplPath);
122123
123- // インクルードディレクティブを処理
124+ // インクルードディレクティブを処理(無限ループにならない様に回数を制限する)
125+ $depth = 0;
124126 while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
127+ if (++$depth > 32) throw new \RuntimeException("インクルードが深過ぎます(循環?):{$tmplPath}");
125128 $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;
127130 if (!file_exists($inclPath))
128131 throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
129132 return file_get_contents($inclPath);
130133 }, $content);
131134 }
132135
136+ $content = $this->procComments($content);
133137 $content = $this->procDirs($content);
134138 $content = $this->procVars($content);
135139 $content = $this->procFuncs($content);
@@ -137,13 +141,48 @@
137141 $tmpFile = tempnam(sys_get_temp_dir(), 'tmpl_');
138142 file_put_contents($tmpFile, $content);
139143
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+ }
142154 }
143155
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+
144172 // 機能性メソッド
145173
146174 /**
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+ /**
147186 * テンプレートディレクティブを処理する
148187 *
149188 * @param string $content テンプレート内容
@@ -153,7 +192,7 @@
153192 // includeディレクティブの処理
154193 while (preg_match('/\{@\s*include\((.*?)\)\s*@\}/s', $content)) {
155194 $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';
157196 if (!file_exists($inclPath))
158197 throw new \RuntimeException("ファイルを見つけません: {$inclPath}");
159198 return file_get_contents($inclPath);
@@ -228,7 +267,7 @@
228267 */
229268 private function procVars(string $content): string {
230269 // 変数の出力(エスケープ処理なし)
231- $content = preg_replace_callback('/\{\{\{s*(.*?)\s*\}\}\}/', function($m): string {
270+ $content = preg_replace_callback('/\{\{\{\s*(.*?)\s*\}\}\}/', function($m): string {
232271 return '<?= '.trim($m[1]).' ?>';
233272 }, $content);
234273
@@ -243,11 +282,6 @@
243282 if (count($parts) !== 2)
244283 throw new \RuntimeException("不正な値の形式");
245284 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]).'*/ ?>';
251285 }, $content);
252286
253287 // PHPコードの実行
@@ -196,7 +196,68 @@
196196 $test->assertEquals($expect, $actual);
197197 });
198198
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+ });
200261
201262 $test->it('画像、音楽、動画はHTMLに交換出来るはず', function($test): void {
202263 mkfile("![](https://ass.technicalsuwako.moe/banner.png)\n