reviw 0.10.6 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +56 -0
  2. package/cli.cjs +1005 -106
  3. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  const fs = require("fs");
15
15
  const http = require("http");
16
+ const os = require("os");
16
17
  const path = require("path");
17
18
  const { spawn } = require("child_process");
18
19
  const chardet = require("chardet");
@@ -20,17 +21,157 @@ const iconv = require("iconv-lite");
20
21
  const marked = require("marked");
21
22
  const yaml = require("js-yaml");
22
23
 
24
+ // --- XSS Protection for marked (Whitelist approach) ---
25
+ // 許可タグリスト(Markdown由来の安全なタグのみ)
26
+ const allowedTags = new Set([
27
+ 'p', 'br', 'hr',
28
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
29
+ 'ul', 'ol', 'li',
30
+ 'blockquote', 'pre', 'code',
31
+ 'em', 'strong', 'del', 's',
32
+ 'a', 'img',
33
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
34
+ 'div', 'span', // Markdown拡張用
35
+ ]);
36
+
37
+ // 許可属性リスト(タグごとに定義)
38
+ const allowedAttributes = {
39
+ 'a': ['href', 'title', 'target', 'rel'],
40
+ 'img': ['src', 'alt', 'title', 'width', 'height'],
41
+ 'code': ['class'], // 言語ハイライト用
42
+ 'pre': ['class'],
43
+ 'div': ['class'],
44
+ 'span': ['class'],
45
+ 'th': ['align'],
46
+ 'td': ['align'],
47
+ };
48
+
49
+ // HTMLエスケープ関数(XSS対策用)
50
+ function escapeHtmlForXss(html) {
51
+ return html
52
+ .replace(/&/g, "&")
53
+ .replace(/</g, "&lt;")
54
+ .replace(/>/g, "&gt;")
55
+ .replace(/"/g, "&quot;")
56
+ .replace(/'/g, "&#39;");
57
+ }
58
+
59
+ // href/src属性のURLバリデーション
60
+ function isSafeUrl(url) {
61
+ if (!url) return true;
62
+ // 空白・制御文字を除去して正規化
63
+ var normalized = url.toLowerCase().replace(/[\s\x00-\x1f]/g, '');
64
+ // HTMLエンティティのデコード(&#x0a; &#10; など)
65
+ var decoded = normalized.replace(/&#x?[0-9a-f]+;?/gi, '');
66
+ if (decoded.startsWith('javascript:')) return false;
67
+ if (decoded.startsWith('vbscript:')) return false;
68
+ if (decoded.startsWith('data:') && !decoded.startsWith('data:image/')) return false;
69
+ return true;
70
+ }
71
+
72
+ // HTML文字列をサニタイズ(ホワイトリストに含まれないタグ/属性を除去)
73
+ function sanitizeHtml(html) {
74
+ // より堅牢なタグマッチング:属性値内の < > を考慮
75
+ // 引用符で囲まれた属性値を正しく処理するパターン
76
+ var tagPattern = /<\/?([a-z][a-z0-9]*)((?:\s+[a-z][a-z0-9-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>"']*))?)*)\s*\/?>/gi;
77
+
78
+ return html.replace(tagPattern, function(match, tag, attrsStr) {
79
+ var tagLower = tag.toLowerCase();
80
+
81
+ // 許可されていないタグはエスケープ
82
+ if (!allowedTags.has(tagLower)) {
83
+ return escapeHtmlForXss(match);
84
+ }
85
+
86
+ // 終了タグはそのまま
87
+ if (match.startsWith('</')) {
88
+ return '</' + tagLower + '>';
89
+ }
90
+
91
+ // 属性をフィルタリング
92
+ var allowed = allowedAttributes[tagLower] || [];
93
+ var safeAttrs = [];
94
+
95
+ // 属性を解析(引用符で囲まれた値を正しく処理)
96
+ var attrRegex = /([a-z][a-z0-9-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']*))/gi;
97
+ var attrMatch;
98
+ while ((attrMatch = attrRegex.exec(attrsStr)) !== null) {
99
+ var attrName = attrMatch[1].toLowerCase();
100
+ var attrValue = attrMatch[2] !== undefined ? attrMatch[2] :
101
+ attrMatch[3] !== undefined ? attrMatch[3] :
102
+ attrMatch[4] || '';
103
+
104
+ // on*イベントハンドラは常に拒否
105
+ if (attrName.startsWith('on')) continue;
106
+
107
+ // 許可属性のみ
108
+ if (!allowed.includes(attrName)) continue;
109
+
110
+ // href/srcのURL検証
111
+ if ((attrName === 'href' || attrName === 'src') && !isSafeUrl(attrValue)) {
112
+ continue;
113
+ }
114
+
115
+ safeAttrs.push(attrName + '="' + attrValue.replace(/"/g, '&quot;') + '"');
116
+ }
117
+
118
+ var finalAttrs = safeAttrs.length > 0 ? ' ' + safeAttrs.join(' ') : '';
119
+ return '<' + tagLower + finalAttrs + '>';
120
+ });
121
+ }
122
+
123
+ marked.use({
124
+ renderer: {
125
+ // 生HTMLブロックをサニタイズ
126
+ html: function(token) {
127
+ var text = token.raw || token.text || token;
128
+ return sanitizeHtml(text);
129
+ },
130
+ // リンクに安全なURL検証を追加(別タブで開く)
131
+ link: function(href, title, text) {
132
+ href = href || "";
133
+ title = title || "";
134
+ text = text || "";
135
+ if (!isSafeUrl(href)) {
136
+ // 危険なURLはプレーンテキストとして表示
137
+ return escapeHtmlForXss(text);
138
+ }
139
+ var titleAttr = title ? ' title="' + escapeHtmlForXss(title) + '"' : "";
140
+ return '<a href="' + escapeHtmlForXss(href) + '"' + titleAttr + ' target="_blank" rel="noopener noreferrer">' + text + '</a>';
141
+ },
142
+ // 画像にも安全なURL検証を追加
143
+ image: function(href, title, text) {
144
+ href = href || "";
145
+ title = title || "";
146
+ text = text || "";
147
+ if (!isSafeUrl(href)) {
148
+ return escapeHtmlForXss(text || "image");
149
+ }
150
+ var titleAttr = title ? ' title="' + escapeHtmlForXss(title) + '"' : "";
151
+ var altAttr = text ? ' alt="' + escapeHtmlForXss(text) + '"' : "";
152
+ return '<img src="' + escapeHtmlForXss(href) + '"' + altAttr + titleAttr + '>';
153
+ }
154
+ }
155
+ });
156
+
23
157
  // --- CLI arguments ---------------------------------------------------------
24
158
  const VERSION = require("./package.json").version;
25
- const args = process.argv.slice(2);
26
159
 
27
- const filePaths = [];
28
- let basePort = 4989;
29
- let encodingOpt = null;
30
- let noOpen = false;
160
+ // ===== CLI設定のデフォルト値(import時に使用) =====
161
+ const DEFAULT_CONFIG = {
162
+ basePort: 4989,
163
+ encodingOpt: null,
164
+ noOpen: false,
165
+ };
166
+
167
+ // ===== グローバル設定変数(デフォルト値で初期化、require.main時に更新) =====
168
+ let basePort = DEFAULT_CONFIG.basePort;
169
+ let encodingOpt = DEFAULT_CONFIG.encodingOpt;
170
+ let noOpen = DEFAULT_CONFIG.noOpen;
31
171
  let stdinMode = false;
32
172
  let diffMode = false;
33
173
  let stdinContent = null;
174
+ let resolvedPaths = []; // ファイルパス(require.main時に設定)
34
175
 
35
176
  function showHelp() {
36
177
  console.log(`reviw v${VERSION} - Lightweight file reviewer with in-browser comments
@@ -75,25 +216,55 @@ function showVersion() {
75
216
  console.log(VERSION);
76
217
  }
77
218
 
78
- for (let i = 0; i < args.length; i += 1) {
79
- const arg = args[i];
80
- if (arg === "--port" && args[i + 1]) {
81
- basePort = Number(args[i + 1]);
82
- i += 1;
83
- } else if ((arg === "--encoding" || arg === "-e") && args[i + 1]) {
84
- encodingOpt = args[i + 1];
85
- i += 1;
86
- } else if (arg === "--no-open") {
87
- noOpen = true;
88
- } else if (arg === "--help" || arg === "-h") {
89
- showHelp();
90
- process.exit(0);
91
- } else if (arg === "--version" || arg === "-v") {
92
- showVersion();
93
- process.exit(0);
94
- } else if (!arg.startsWith("-")) {
95
- filePaths.push(arg);
219
+ // ===== CLI引数パース関数(require.main時のみ呼ばれる) =====
220
+ function parseCliArgs(argv) {
221
+ const args = argv.slice(2);
222
+ const config = { ...DEFAULT_CONFIG };
223
+ const filePaths = [];
224
+
225
+ for (let i = 0; i < args.length; i += 1) {
226
+ const arg = args[i];
227
+ if (arg === "--port" && args[i + 1]) {
228
+ config.basePort = Number(args[i + 1]);
229
+ i += 1;
230
+ } else if ((arg === "--encoding" || arg === "-e") && args[i + 1]) {
231
+ config.encodingOpt = args[i + 1];
232
+ i += 1;
233
+ } else if (arg === "--no-open") {
234
+ config.noOpen = true;
235
+ } else if (arg === "--help" || arg === "-h") {
236
+ showHelp();
237
+ process.exit(0);
238
+ } else if (arg === "--version" || arg === "-v") {
239
+ showVersion();
240
+ process.exit(0);
241
+ } else if (!arg.startsWith("-")) {
242
+ filePaths.push(arg);
243
+ }
96
244
  }
245
+
246
+ return { config, filePaths };
247
+ }
248
+
249
+ // ===== ファイルパス検証・解決関数(require.main時のみ呼ばれる) =====
250
+ function validateAndResolvePaths(filePaths) {
251
+ const resolved = [];
252
+ for (const fp of filePaths) {
253
+ const resolvedPath = path.resolve(fp);
254
+ if (!fs.existsSync(resolvedPath)) {
255
+ console.error(`File not found: ${resolvedPath}`);
256
+ process.exit(1);
257
+ }
258
+ const stat = fs.statSync(resolvedPath);
259
+ if (stat.isDirectory()) {
260
+ console.error(`Cannot open directory: ${resolvedPath}`);
261
+ console.error(`Usage: reviw <file> [file2...]`);
262
+ console.error(`Please specify a file, not a directory.`);
263
+ process.exit(1);
264
+ }
265
+ resolved.push(resolvedPath);
266
+ }
267
+ return resolved;
97
268
  }
98
269
 
99
270
  // Check if stdin has data (pipe mode)
@@ -148,24 +319,6 @@ function runGitDiff() {
148
319
  });
149
320
  }
150
321
 
151
- // Validate all files exist and are not directories (if files specified)
152
- const resolvedPaths = [];
153
- for (const fp of filePaths) {
154
- const resolved = path.resolve(fp);
155
- if (!fs.existsSync(resolved)) {
156
- console.error(`File not found: ${resolved}`);
157
- process.exit(1);
158
- }
159
- const stat = fs.statSync(resolved);
160
- if (stat.isDirectory()) {
161
- console.error(`Cannot open directory: ${resolved}`);
162
- console.error(`Usage: reviw <file> [file2...]`);
163
- console.error(`Please specify a file, not a directory.`);
164
- process.exit(1);
165
- }
166
- resolvedPaths.push(resolved);
167
- }
168
-
169
322
  // --- Diff parsing -----------------------------------------------------------
170
323
  function parseDiff(diffText) {
171
324
  const files = [];
@@ -313,7 +466,8 @@ function loadDiff(diffText) {
313
466
  return {
314
467
  rows,
315
468
  files: sortedFiles,
316
- title: "Git Diff",
469
+ projectRoot: "",
470
+ relativePath: "Git Diff",
317
471
  mode: "diff",
318
472
  };
319
473
  }
@@ -422,7 +576,7 @@ function loadCsv(filePath) {
422
576
  return {
423
577
  rows,
424
578
  cols: Math.max(1, maxCols),
425
- title: path.basename(filePath),
579
+ ...formatTitlePaths(filePath),
426
580
  };
427
581
  }
428
582
 
@@ -433,7 +587,7 @@ function loadText(filePath) {
433
587
  return {
434
588
  rows: lines.map((line) => [line]),
435
589
  cols: 1,
436
- title: path.basename(filePath),
590
+ ...formatTitlePaths(filePath),
437
591
  preview: null,
438
592
  };
439
593
  }
@@ -446,6 +600,7 @@ function loadMarkdown(filePath) {
446
600
  // Parse YAML frontmatter
447
601
  let frontmatterHtml = "";
448
602
  let contentStart = 0;
603
+ let reviwQuestions = []; // Extract reviw questions for modal
449
604
 
450
605
  if (lines[0] && lines[0].trim() === "---") {
451
606
  let frontmatterEnd = -1;
@@ -463,30 +618,91 @@ function loadMarkdown(filePath) {
463
618
  try {
464
619
  const frontmatter = yaml.load(frontmatterText);
465
620
  if (frontmatter && typeof frontmatter === "object") {
466
- // Create HTML table for frontmatter
467
- frontmatterHtml = '<div class="frontmatter-table"><table>';
468
- frontmatterHtml += '<colgroup><col style="width:12%"><col style="width:88%"></colgroup>';
469
- frontmatterHtml += '<thead><tr><th colspan="2">Document Metadata</th></tr></thead>';
470
- frontmatterHtml += "<tbody>";
471
-
472
- function renderValue(val) {
473
- if (Array.isArray(val)) {
474
- return val
475
- .map((v) => '<span class="fm-tag">' + escapeHtmlChars(String(v)) + "</span>")
476
- .join(" ");
621
+ // Extract reviw questions if present
622
+ if (frontmatter.reviw && Array.isArray(frontmatter.reviw.questions)) {
623
+ reviwQuestions = frontmatter.reviw.questions.map((q, idx) => ({
624
+ id: q.id || "q" + (idx + 1),
625
+ question: q.question || "",
626
+ resolved: q.resolved === true,
627
+ answer: q.answer || "",
628
+ options: Array.isArray(q.options) ? q.options : [],
629
+ }));
630
+ }
631
+
632
+ // Create HTML table for frontmatter (show reviw questions in detail for 1:1 correspondence with YAML source)
633
+ const displayFrontmatter = { ...frontmatter };
634
+ // Keep reviw as-is for detailed rendering
635
+
636
+ if (Object.keys(displayFrontmatter).length > 0) {
637
+ frontmatterHtml = '<div class="frontmatter-table"><table>';
638
+ frontmatterHtml += '<colgroup><col style="width:12%"><col style="width:88%"></colgroup>';
639
+ frontmatterHtml += '<thead><tr><th colspan="2">Document Metadata</th></tr></thead>';
640
+ frontmatterHtml += "<tbody>";
641
+
642
+ // Render reviw.questions as detailed cards
643
+ function renderReviwQuestions(questions) {
644
+ if (!Array.isArray(questions) || questions.length === 0) {
645
+ return '<span class="fm-tag">質問なし</span>';
646
+ }
647
+ let html = '<div class="reviw-questions-preview">';
648
+ questions.forEach((q, idx) => {
649
+ const statusIcon = q.resolved ? '✅' : '⏳';
650
+ const statusClass = q.resolved ? 'resolved' : 'pending';
651
+ html += '<div class="reviw-q-card ' + statusClass + '">';
652
+ html += '<div class="reviw-q-header">' + statusIcon + ' <strong>' + escapeHtmlChars(q.id || 'Q' + (idx + 1)) + '</strong></div>';
653
+ html += '<div class="reviw-q-question">' + escapeHtmlChars(q.question || '') + '</div>';
654
+ if (q.options && Array.isArray(q.options) && q.options.length > 0) {
655
+ html += '<div class="reviw-q-options">';
656
+ q.options.forEach(opt => {
657
+ html += '<span class="fm-tag">' + escapeHtmlChars(opt) + '</span>';
658
+ });
659
+ html += '</div>';
660
+ }
661
+ if (q.answer) {
662
+ html += '<div class="reviw-q-answer">💬 ' + escapeHtmlChars(q.answer) + '</div>';
663
+ }
664
+ html += '</div>';
665
+ });
666
+ html += '</div>';
667
+ return html;
477
668
  }
478
- if (typeof val === "object" && val !== null) {
479
- return "<pre>" + escapeHtmlChars(JSON.stringify(val, null, 2)) + "</pre>";
669
+
670
+ function renderValue(val, key) {
671
+ // Special handling for reviw object
672
+ if (key === 'reviw' && typeof val === 'object' && val !== null) {
673
+ let html = '';
674
+ if (val.questions && Array.isArray(val.questions)) {
675
+ html += renderReviwQuestions(val.questions);
676
+ }
677
+ // Render other reviw properties
678
+ const { questions, ...rest } = val;
679
+ if (Object.keys(rest).length > 0) {
680
+ html += '<div style="margin-top: 8px;">';
681
+ for (const [k, v] of Object.entries(rest)) {
682
+ html += '<div><strong>' + escapeHtmlChars(k) + ':</strong> ' + escapeHtmlChars(String(v)) + '</div>';
683
+ }
684
+ html += '</div>';
685
+ }
686
+ return html || '<span class="fm-tag">-</span>';
687
+ }
688
+ if (Array.isArray(val)) {
689
+ return val
690
+ .map((v) => '<span class="fm-tag">' + escapeHtmlChars(String(v)) + "</span>")
691
+ .join(" ");
692
+ }
693
+ if (typeof val === "object" && val !== null) {
694
+ return "<pre>" + escapeHtmlChars(JSON.stringify(val, null, 2)) + "</pre>";
695
+ }
696
+ return escapeHtmlChars(String(val));
480
697
  }
481
- return escapeHtmlChars(String(val));
482
- }
483
698
 
484
- for (const [key, val] of Object.entries(frontmatter)) {
485
- frontmatterHtml +=
486
- "<tr><th>" + escapeHtmlChars(key) + "</th><td>" + renderValue(val) + "</td></tr>";
487
- }
699
+ for (const [key, val] of Object.entries(displayFrontmatter)) {
700
+ frontmatterHtml +=
701
+ "<tr><th>" + escapeHtmlChars(key) + "</th><td>" + renderValue(val, key) + "</td></tr>";
702
+ }
488
703
 
489
- frontmatterHtml += "</tbody></table></div>";
704
+ frontmatterHtml += "</tbody></table></div>";
705
+ }
490
706
  contentStart = frontmatterEnd + 1;
491
707
  }
492
708
  } catch (e) {
@@ -502,8 +718,9 @@ function loadMarkdown(filePath) {
502
718
  return {
503
719
  rows: lines.map((line) => [line]),
504
720
  cols: 1,
505
- title: path.basename(filePath),
721
+ ...formatTitlePaths(filePath),
506
722
  preview,
723
+ reviwQuestions, // Pass questions to UI
507
724
  };
508
725
  }
509
726
 
@@ -515,6 +732,20 @@ function escapeHtmlChars(str) {
515
732
  .replace(/"/g, "&quot;");
516
733
  }
517
734
 
735
+ function formatTitlePaths(filePath) {
736
+ const cwd = process.cwd();
737
+ const home = os.homedir();
738
+ const relativePath = path.relative(cwd, filePath) || path.basename(filePath);
739
+ let projectRoot = cwd;
740
+ if (projectRoot.startsWith(home)) {
741
+ projectRoot = "~" + projectRoot.slice(home.length);
742
+ }
743
+ if (!projectRoot.endsWith("/")) {
744
+ projectRoot += "/";
745
+ }
746
+ return { projectRoot, relativePath };
747
+ }
748
+
518
749
  function loadData(filePath) {
519
750
  // Check if path exists
520
751
  if (!fs.existsSync(filePath)) {
@@ -562,7 +793,7 @@ function serializeForScript(value) {
562
793
  }
563
794
 
564
795
  function diffHtmlTemplate(diffData) {
565
- const { rows, title } = diffData;
796
+ const { rows, projectRoot, relativePath } = diffData;
566
797
  const serialized = serializeForScript(rows);
567
798
  const fileCount = rows.filter((r) => r.type === "file").length;
568
799
 
@@ -574,7 +805,7 @@ function diffHtmlTemplate(diffData) {
574
805
  <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
575
806
  <meta http-equiv="Pragma" content="no-cache" />
576
807
  <meta http-equiv="Expires" content="0" />
577
- <title>${title} | reviw</title>
808
+ <title>${relativePath} | reviw</title>
578
809
  <style>
579
810
  :root {
580
811
  color-scheme: dark;
@@ -643,7 +874,9 @@ function diffHtmlTemplate(diffData) {
643
874
  }
644
875
  header .meta { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
645
876
  header .actions { display: flex; gap: 8px; align-items: center; }
646
- header h1 { font-size: 16px; margin: 0; font-weight: 600; }
877
+ header h1 { display: flex; flex-direction: column; margin: 0; line-height: 1.3; }
878
+ header h1 .title-path { font-size: 11px; font-weight: 400; color: var(--muted); }
879
+ header h1 .title-file { font-size: 16px; font-weight: 600; }
647
880
  header .badge {
648
881
  background: var(--selected-bg);
649
882
  color: var(--text);
@@ -806,11 +1039,14 @@ function diffHtmlTemplate(diffData) {
806
1039
  display: none;
807
1040
  }
808
1041
  .floating header {
809
- position: relative;
1042
+ position: static;
810
1043
  background: transparent;
1044
+ backdrop-filter: none;
811
1045
  border: none;
812
1046
  padding: 0 0 10px 0;
1047
+ display: flex;
813
1048
  justify-content: space-between;
1049
+ align-items: center;
814
1050
  }
815
1051
  .floating h2 { font-size: 14px; margin: 0; font-weight: 600; }
816
1052
  .floating button {
@@ -835,6 +1071,10 @@ function diffHtmlTemplate(diffData) {
835
1071
  font-size: 13px;
836
1072
  font-family: inherit;
837
1073
  }
1074
+ .floating textarea:focus {
1075
+ outline: none;
1076
+ border-color: var(--accent);
1077
+ }
838
1078
  .floating .actions {
839
1079
  display: flex;
840
1080
  gap: 8px;
@@ -952,7 +1192,7 @@ function diffHtmlTemplate(diffData) {
952
1192
  <body>
953
1193
  <header>
954
1194
  <div class="meta">
955
- <h1>${title}</h1>
1195
+ <h1>${projectRoot ? `<span class="title-path">${projectRoot}</span>` : ""}<span class="title-file">${relativePath}</span></h1>
956
1196
  <span class="badge">${fileCount} file${fileCount !== 1 ? "s" : ""} changed</span>
957
1197
  <span class="pill">Comments <strong id="comment-count">0</strong></span>
958
1198
  </div>
@@ -1387,7 +1627,19 @@ function diffHtmlTemplate(diffData) {
1387
1627
  globalComment = globalCommentInput.value;
1388
1628
  hideSubmitModal();
1389
1629
  sendAndExit('button');
1390
- setTimeout(() => window.close(), 200);
1630
+ // Try to close window; if it fails (browser security), show completion message
1631
+ setTimeout(() => {
1632
+ window.close();
1633
+ // If window.close() didn't work, show a completion message
1634
+ setTimeout(() => {
1635
+ document.body.innerHTML = \`
1636
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;background:var(--bg,#1a1a2e);color:var(--text,#e0e0e0);font-family:system-ui,sans-serif;">
1637
+ <h1 style="font-size:2rem;margin-bottom:1rem;">✅ Review Submitted</h1>
1638
+ <p style="color:var(--muted,#888);">You can close this tab now.</p>
1639
+ </div>
1640
+ \`;
1641
+ }, 100);
1642
+ }, 200);
1391
1643
  }
1392
1644
  document.getElementById('modal-submit').addEventListener('click', doSubmit);
1393
1645
  globalCommentInput.addEventListener('keydown', e => {
@@ -1430,10 +1682,11 @@ function diffHtmlTemplate(diffData) {
1430
1682
  }
1431
1683
 
1432
1684
  // --- HTML template ---------------------------------------------------------
1433
- function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1685
+ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHtml, reviwQuestions = []) {
1434
1686
  const serialized = serializeForScript(dataRows);
1435
1687
  const modeJson = serializeForScript(mode);
1436
- const titleJson = serializeForScript(title);
1688
+ const titleJson = serializeForScript(relativePath); // Use relativePath as file identifier
1689
+ const questionsJson = serializeForScript(reviwQuestions || []);
1437
1690
  const hasPreview = !!previewHtml;
1438
1691
  return `<!doctype html>
1439
1692
  <html lang="ja">
@@ -1443,7 +1696,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1443
1696
  <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
1444
1697
  <meta http-equiv="Pragma" content="no-cache" />
1445
1698
  <meta http-equiv="Expires" content="0" />
1446
- <title>${title} | reviw</title>
1699
+ <title>${relativePath} | reviw</title>
1447
1700
  <style>
1448
1701
  /* Dark theme (default) */
1449
1702
  :root {
@@ -1470,6 +1723,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1470
1723
  --hover-bg: rgba(96,165,250,0.08);
1471
1724
  --shadow-color: rgba(0,0,0,0.35);
1472
1725
  --code-bg: #1e293b;
1726
+ --error: #dc3545;
1473
1727
  }
1474
1728
  /* Light theme */
1475
1729
  [data-theme="light"] {
@@ -1496,6 +1750,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1496
1750
  --hover-bg: rgba(59,130,246,0.06);
1497
1751
  --shadow-color: rgba(0,0,0,0.1);
1498
1752
  --code-bg: #f1f5f9;
1753
+ --error: #dc3545;
1499
1754
  }
1500
1755
  * { box-sizing: border-box; }
1501
1756
  body {
@@ -1522,7 +1777,9 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1522
1777
  }
1523
1778
  header .meta { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
1524
1779
  header .actions { display: flex; gap: 8px; align-items: center; }
1525
- header h1 { font-size: 16px; margin: 0; font-weight: 700; }
1780
+ header h1 { display: flex; flex-direction: column; margin: 0; line-height: 1.3; }
1781
+ header h1 .title-path { font-size: 11px; font-weight: 400; color: var(--muted); }
1782
+ header h1 .title-file { font-size: 16px; font-weight: 700; }
1526
1783
  header .badge {
1527
1784
  background: var(--selected-bg);
1528
1785
  color: var(--text);
@@ -1743,12 +2000,14 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1743
2000
  transition: background 200ms ease, border-color 200ms ease;
1744
2001
  }
1745
2002
  .floating header {
1746
- position: relative;
1747
- top: 0;
2003
+ position: static;
1748
2004
  background: transparent;
2005
+ backdrop-filter: none;
1749
2006
  border: none;
1750
2007
  padding: 0 0 8px 0;
2008
+ display: flex;
1751
2009
  justify-content: space-between;
2010
+ align-items: center;
1752
2011
  }
1753
2012
  .floating h2 { font-size: 14px; margin: 0; color: var(--text); }
1754
2013
  .floating button {
@@ -1777,6 +2036,10 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1777
2036
  line-height: 1.4;
1778
2037
  transition: background 200ms ease, border-color 200ms ease;
1779
2038
  }
2039
+ .floating textarea:focus {
2040
+ outline: none;
2041
+ border-color: var(--accent);
2042
+ }
1780
2043
  .floating .actions {
1781
2044
  display: flex;
1782
2045
  gap: 8px;
@@ -1954,6 +2217,50 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1954
2217
  border-radius: 4px;
1955
2218
  font-size: 11px;
1956
2219
  }
2220
+ /* Reviw questions preview cards */
2221
+ .reviw-questions-preview {
2222
+ display: flex;
2223
+ flex-direction: column;
2224
+ gap: 8px;
2225
+ }
2226
+ .reviw-q-card {
2227
+ background: var(--code-bg);
2228
+ border: 1px solid var(--border);
2229
+ border-radius: 8px;
2230
+ padding: 10px 12px;
2231
+ }
2232
+ .reviw-q-card.resolved {
2233
+ border-left: 3px solid #22c55e;
2234
+ }
2235
+ .reviw-q-card.pending {
2236
+ border-left: 3px solid #f59e0b;
2237
+ }
2238
+ .reviw-q-header {
2239
+ font-size: 12px;
2240
+ color: var(--text-dim);
2241
+ margin-bottom: 4px;
2242
+ }
2243
+ .reviw-q-header strong {
2244
+ color: var(--accent);
2245
+ }
2246
+ .reviw-q-question {
2247
+ font-size: 13px;
2248
+ color: var(--text);
2249
+ margin-bottom: 6px;
2250
+ }
2251
+ .reviw-q-options {
2252
+ display: flex;
2253
+ flex-wrap: wrap;
2254
+ gap: 4px;
2255
+ margin-bottom: 6px;
2256
+ }
2257
+ .reviw-q-answer {
2258
+ font-size: 12px;
2259
+ color: #22c55e;
2260
+ background: rgba(34, 197, 94, 0.1);
2261
+ padding: 4px 8px;
2262
+ border-radius: 4px;
2263
+ }
1957
2264
  [data-theme="light"] .frontmatter-table tbody th {
1958
2265
  color: #7c3aed;
1959
2266
  }
@@ -1973,9 +2280,6 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1973
2280
  text-align: left;
1974
2281
  border-bottom: 1px solid var(--border);
1975
2282
  }
1976
- .md-preview table:not(.frontmatter-table table) th {
1977
- background: rgba(255,255,255,0.05);
1978
- }
1979
2283
  .md-preview table:not(.frontmatter-table table) th {
1980
2284
  background: var(--panel);
1981
2285
  font-weight: 600;
@@ -2104,6 +2408,267 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2104
2408
  border-radius: 8px;
2105
2409
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
2106
2410
  }
2411
+ /* Reviw Questions Modal */
2412
+ .reviw-questions-overlay {
2413
+ display: none;
2414
+ position: fixed;
2415
+ inset: 0;
2416
+ background: rgba(0, 0, 0, 0.8);
2417
+ z-index: 1100;
2418
+ justify-content: center;
2419
+ align-items: center;
2420
+ }
2421
+ .reviw-questions-overlay.visible {
2422
+ display: flex;
2423
+ }
2424
+ .reviw-questions-modal {
2425
+ background: var(--card-bg);
2426
+ border: 1px solid var(--border);
2427
+ border-radius: 16px;
2428
+ width: 90%;
2429
+ max-width: 600px;
2430
+ max-height: 80vh;
2431
+ display: flex;
2432
+ flex-direction: column;
2433
+ box-shadow: 0 25px 80px rgba(0, 0, 0, 0.5);
2434
+ }
2435
+ .reviw-questions-header {
2436
+ display: flex;
2437
+ justify-content: space-between;
2438
+ align-items: center;
2439
+ padding: 16px 20px;
2440
+ border-bottom: 1px solid var(--border);
2441
+ }
2442
+ .reviw-questions-header h2 {
2443
+ margin: 0;
2444
+ font-size: 16px;
2445
+ font-weight: 600;
2446
+ color: var(--text);
2447
+ }
2448
+ .reviw-questions-header h2 span {
2449
+ font-size: 14px;
2450
+ color: var(--text-dim);
2451
+ font-weight: 400;
2452
+ }
2453
+ .reviw-questions-close {
2454
+ width: 32px;
2455
+ height: 32px;
2456
+ border: none;
2457
+ background: transparent;
2458
+ color: var(--text-dim);
2459
+ font-size: 18px;
2460
+ cursor: pointer;
2461
+ border-radius: 8px;
2462
+ transition: all 150ms ease;
2463
+ }
2464
+ .reviw-questions-close:hover {
2465
+ background: var(--border);
2466
+ color: var(--text);
2467
+ }
2468
+ .reviw-questions-body {
2469
+ flex: 1;
2470
+ overflow-y: auto;
2471
+ padding: 16px 20px;
2472
+ }
2473
+ .reviw-questions-footer {
2474
+ padding: 12px 20px;
2475
+ border-top: 1px solid var(--border);
2476
+ display: flex;
2477
+ justify-content: flex-end;
2478
+ }
2479
+ .reviw-questions-later {
2480
+ padding: 8px 16px;
2481
+ border: 1px solid var(--border);
2482
+ background: transparent;
2483
+ color: var(--text-dim);
2484
+ border-radius: 8px;
2485
+ cursor: pointer;
2486
+ font-size: 13px;
2487
+ transition: all 150ms ease;
2488
+ }
2489
+ .reviw-questions-later:hover {
2490
+ background: var(--border);
2491
+ color: var(--text);
2492
+ }
2493
+ /* Question Item */
2494
+ .reviw-question-item {
2495
+ margin-bottom: 20px;
2496
+ padding-bottom: 20px;
2497
+ border-bottom: 1px solid var(--border);
2498
+ }
2499
+ .reviw-question-item:last-child {
2500
+ margin-bottom: 0;
2501
+ padding-bottom: 0;
2502
+ border-bottom: none;
2503
+ }
2504
+ .reviw-question-text {
2505
+ font-size: 14px;
2506
+ color: var(--text);
2507
+ margin-bottom: 12px;
2508
+ line-height: 1.5;
2509
+ }
2510
+ .reviw-question-options {
2511
+ display: flex;
2512
+ flex-wrap: wrap;
2513
+ gap: 8px;
2514
+ margin-bottom: 12px;
2515
+ }
2516
+ .reviw-question-option {
2517
+ padding: 8px 14px;
2518
+ border: 1px solid var(--border);
2519
+ background: transparent;
2520
+ color: var(--text);
2521
+ border-radius: 8px;
2522
+ cursor: pointer;
2523
+ font-size: 13px;
2524
+ transition: all 150ms ease;
2525
+ }
2526
+ .reviw-question-option:hover {
2527
+ border-color: var(--accent);
2528
+ background: rgba(96, 165, 250, 0.1);
2529
+ }
2530
+ .reviw-question-option.selected {
2531
+ border-color: var(--accent);
2532
+ background: var(--accent);
2533
+ color: var(--text-inverse);
2534
+ }
2535
+ .reviw-question-input {
2536
+ width: 100%;
2537
+ padding: 10px 12px;
2538
+ border: 1px solid var(--border);
2539
+ background: var(--input-bg);
2540
+ color: var(--text);
2541
+ border-radius: 8px;
2542
+ font-size: 13px;
2543
+ resize: vertical;
2544
+ min-height: 60px;
2545
+ }
2546
+ .reviw-question-input:focus {
2547
+ outline: none;
2548
+ border-color: var(--accent);
2549
+ }
2550
+ .reviw-question-input::placeholder {
2551
+ color: var(--text-dim);
2552
+ }
2553
+ .reviw-check-mark {
2554
+ color: #22c55e;
2555
+ font-weight: bold;
2556
+ }
2557
+ .reviw-question-item.answered {
2558
+ border-color: #22c55e;
2559
+ background: rgba(34, 197, 94, 0.05);
2560
+ }
2561
+ .reviw-question-submit {
2562
+ margin-top: 10px;
2563
+ padding: 8px 16px;
2564
+ border: none;
2565
+ background: var(--accent);
2566
+ color: var(--text-inverse);
2567
+ border-radius: 8px;
2568
+ cursor: pointer;
2569
+ font-size: 13px;
2570
+ font-weight: 500;
2571
+ transition: all 150ms ease;
2572
+ }
2573
+ .reviw-question-submit:hover {
2574
+ filter: brightness(1.1);
2575
+ }
2576
+ .reviw-question-submit:disabled {
2577
+ opacity: 0.5;
2578
+ cursor: not-allowed;
2579
+ }
2580
+ /* Resolved Section */
2581
+ .reviw-resolved-section {
2582
+ margin-top: 16px;
2583
+ border-top: 1px solid var(--border);
2584
+ padding-top: 12px;
2585
+ }
2586
+ .reviw-resolved-toggle {
2587
+ display: flex;
2588
+ align-items: center;
2589
+ gap: 8px;
2590
+ background: none;
2591
+ border: none;
2592
+ color: var(--text-dim);
2593
+ font-size: 13px;
2594
+ cursor: pointer;
2595
+ padding: 4px 0;
2596
+ }
2597
+ .reviw-resolved-toggle:hover {
2598
+ color: var(--text);
2599
+ }
2600
+ .reviw-resolved-toggle .arrow {
2601
+ transition: transform 150ms ease;
2602
+ }
2603
+ .reviw-resolved-toggle.open .arrow {
2604
+ transform: rotate(90deg);
2605
+ }
2606
+ .reviw-resolved-list {
2607
+ display: none;
2608
+ margin-top: 12px;
2609
+ }
2610
+ .reviw-resolved-list.visible {
2611
+ display: block;
2612
+ }
2613
+ .reviw-resolved-item {
2614
+ padding: 10px 12px;
2615
+ background: var(--input-bg);
2616
+ border-radius: 8px;
2617
+ margin-bottom: 8px;
2618
+ opacity: 0.7;
2619
+ }
2620
+ .reviw-resolved-item:last-child {
2621
+ margin-bottom: 0;
2622
+ }
2623
+ .reviw-resolved-q {
2624
+ font-size: 12px;
2625
+ color: var(--text-dim);
2626
+ margin-bottom: 4px;
2627
+ }
2628
+ .reviw-resolved-a {
2629
+ font-size: 13px;
2630
+ color: var(--text);
2631
+ }
2632
+ /* Notice Bar */
2633
+ .reviw-questions-bar {
2634
+ display: none;
2635
+ position: fixed;
2636
+ top: 0;
2637
+ left: 0;
2638
+ right: 0;
2639
+ background: var(--accent);
2640
+ color: var(--text-inverse);
2641
+ padding: 8px 16px;
2642
+ font-size: 13px;
2643
+ z-index: 1050;
2644
+ justify-content: center;
2645
+ align-items: center;
2646
+ gap: 12px;
2647
+ }
2648
+ .reviw-questions-bar.visible {
2649
+ display: flex;
2650
+ }
2651
+ .reviw-questions-bar button {
2652
+ padding: 4px 12px;
2653
+ border: 1px solid rgba(255, 255, 255, 0.3);
2654
+ background: rgba(255, 255, 255, 0.1);
2655
+ color: var(--text-inverse);
2656
+ border-radius: 6px;
2657
+ cursor: pointer;
2658
+ font-size: 12px;
2659
+ transition: all 150ms ease;
2660
+ }
2661
+ .reviw-questions-bar button:hover {
2662
+ background: rgba(255, 255, 255, 0.2);
2663
+ }
2664
+ /* Adjust layout when bar is visible */
2665
+ body.has-questions-bar header {
2666
+ top: 36px;
2667
+ }
2668
+ body.has-questions-bar .toolbar,
2669
+ body.has-questions-bar .table-wrap {
2670
+ margin-top: 36px;
2671
+ }
2107
2672
  /* Copy notification toast */
2108
2673
  .copy-toast {
2109
2674
  position: fixed;
@@ -2344,7 +2909,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2344
2909
  bottom: 20px;
2345
2910
  left: 50%;
2346
2911
  transform: translateX(-50%);
2347
- background: #dc3545;
2912
+ background: var(--error);
2348
2913
  color: white;
2349
2914
  padding: 12px 24px;
2350
2915
  border-radius: 8px;
@@ -2366,7 +2931,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2366
2931
  <body>
2367
2932
  <header>
2368
2933
  <div class="meta">
2369
- <h1>${title}</h1>
2934
+ <h1><span class="title-path">${projectRoot}</span><span class="title-file">${relativePath}</span></h1>
2370
2935
  <span class="badge">Click to comment / ESC to cancel</span>
2371
2936
  <span class="pill">Comments <strong id="comment-count">0</strong></span>
2372
2937
  </div>
@@ -2519,11 +3084,32 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2519
3084
  <div class="video-container" id="video-container"></div>
2520
3085
  </div>
2521
3086
 
3087
+ <!-- Reviw Questions Modal -->
3088
+ <div class="reviw-questions-overlay" id="reviw-questions-overlay">
3089
+ <div class="reviw-questions-modal" id="reviw-questions-modal">
3090
+ <div class="reviw-questions-header">
3091
+ <h2>📋 AIからの質問 <span id="reviw-questions-count"></span></h2>
3092
+ <button class="reviw-questions-close" id="reviw-questions-close" aria-label="Close">✕</button>
3093
+ </div>
3094
+ <div class="reviw-questions-body" id="reviw-questions-body"></div>
3095
+ <div class="reviw-questions-footer">
3096
+ <button class="reviw-questions-later" id="reviw-questions-later">後で回答する</button>
3097
+ </div>
3098
+ </div>
3099
+ </div>
3100
+
3101
+ <!-- Reviw Questions Notice Bar -->
3102
+ <div class="reviw-questions-bar" id="reviw-questions-bar">
3103
+ <span id="reviw-questions-bar-message">\ud83d\udccb \u672a\u56de\u7b54\u306e\u8cea\u554f\u304c<span id="reviw-questions-bar-count">0</span>\u4ef6\u3042\u308a\u307e\u3059</span>
3104
+ <button id="reviw-questions-bar-open">\u8cea\u554f\u3092\u898b\u308b</button>
3105
+ </div>
3106
+
2522
3107
  <script>
2523
3108
  const DATA = ${serialized};
2524
3109
  const MAX_COLS = ${cols};
2525
3110
  const FILE_NAME = ${titleJson};
2526
3111
  const MODE = ${modeJson};
3112
+ const REVIW_QUESTIONS = ${questionsJson};
2527
3113
 
2528
3114
  // --- Theme Management ---
2529
3115
  (function initTheme() {
@@ -2882,6 +3468,12 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2882
3468
 
2883
3469
  function openCardForSelection() {
2884
3470
  if (!selection) return;
3471
+ // Don't open card while image/video modal is visible
3472
+ const imageOverlay = document.getElementById('image-fullscreen');
3473
+ const videoOverlay = document.getElementById('video-fullscreen');
3474
+ if (imageOverlay?.classList.contains('visible') || videoOverlay?.classList.contains('visible')) {
3475
+ return;
3476
+ }
2885
3477
  const { startRow, endRow, startCol, endCol } = selection;
2886
3478
  const isSingleCell = startRow === endRow && startCol === endCol;
2887
3479
 
@@ -3398,6 +3990,22 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3398
3990
  if (globalComment.trim()) {
3399
3991
  data.summary = globalComment.trim();
3400
3992
  }
3993
+ // Include answered questions
3994
+ if (window.REVIW_ANSWERS) {
3995
+ const answeredQuestions = [];
3996
+ for (const [id, answer] of Object.entries(window.REVIW_ANSWERS)) {
3997
+ if (answer.selected || answer.text.trim()) {
3998
+ answeredQuestions.push({
3999
+ id,
4000
+ selected: answer.selected,
4001
+ text: answer.text.trim()
4002
+ });
4003
+ }
4004
+ }
4005
+ if (answeredQuestions.length > 0) {
4006
+ data.reviwAnswers = answeredQuestions;
4007
+ }
4008
+ }
3401
4009
  return data;
3402
4010
  }
3403
4011
  function sendAndExit(reason = 'pagehide') {
@@ -3425,7 +4033,19 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3425
4033
  globalComment = globalCommentInput.value;
3426
4034
  hideSubmitModal();
3427
4035
  sendAndExit('button');
3428
- setTimeout(() => window.close(), 200);
4036
+ // Try to close window; if it fails (browser security), show completion message
4037
+ setTimeout(() => {
4038
+ window.close();
4039
+ // If window.close() didn't work, show a completion message
4040
+ setTimeout(() => {
4041
+ document.body.innerHTML = \`
4042
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;background:var(--bg,#1a1a2e);color:var(--text,#e0e0e0);font-family:system-ui,sans-serif;">
4043
+ <h1 style="font-size:2rem;margin-bottom:1rem;">✅ Review Submitted</h1>
4044
+ <p style="color:var(--muted,#888);">You can close this tab now.</p>
4045
+ </div>
4046
+ \`;
4047
+ }, 100);
4048
+ }, 200);
3429
4049
  }
3430
4050
  modalSubmit.addEventListener('click', doSubmit);
3431
4051
  globalCommentInput.addEventListener('keydown', (e) => {
@@ -3867,8 +4487,9 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3867
4487
  }
3868
4488
 
3869
4489
  if (imageOverlay) {
4490
+ // Close on any click (including image itself)
3870
4491
  imageOverlay.addEventListener('click', (e) => {
3871
- if (e.target === imageOverlay) closeImageOverlay();
4492
+ closeImageOverlay();
3872
4493
  });
3873
4494
  }
3874
4495
 
@@ -3883,7 +4504,8 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3883
4504
  img.title = 'Click to view fullscreen';
3884
4505
 
3885
4506
  img.addEventListener('click', (e) => {
3886
- e.stopPropagation();
4507
+ // Don't stop propagation - allow select to work
4508
+ e.preventDefault();
3887
4509
 
3888
4510
  imageContainer.innerHTML = '';
3889
4511
  const clonedImg = img.cloneNode(true);
@@ -3928,8 +4550,9 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3928
4550
  }
3929
4551
 
3930
4552
  if (videoOverlay) {
4553
+ // Close on any click (including video itself)
3931
4554
  videoOverlay.addEventListener('click', (e) => {
3932
- if (e.target === videoOverlay) closeVideoOverlay();
4555
+ closeVideoOverlay();
3933
4556
  });
3934
4557
  }
3935
4558
 
@@ -3948,7 +4571,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3948
4571
 
3949
4572
  link.addEventListener('click', (e) => {
3950
4573
  e.preventDefault();
3951
- e.stopPropagation();
4574
+ // Don't stop propagation - allow select to work
3952
4575
 
3953
4576
  // Remove existing video if any
3954
4577
  const existingVideo = videoContainer.querySelector('video');
@@ -4034,13 +4657,11 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
4034
4657
  }
4035
4658
  }
4036
4659
 
4037
- // Check for markdown list items: strip list markers and formatting
4038
- if (lineText.match(/^[-*+]\\s|^\\d+\\.\\s/)) {
4039
- const strippedLine = stripMarkdown(lineText).replace(/\\s+/g, ' ').slice(0, 100);
4040
- if (strippedLine === normalized) return i + 1;
4041
- if (strippedLine.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
4042
- if (normalized.includes(strippedLine.slice(0, 30)) && strippedLine.length > 5) return i + 1;
4043
- }
4660
+ // Try stripping all markdown formatting (links, bold, italic, etc.)
4661
+ const strippedLine = stripMarkdown(lineText).replace(/\\s+/g, ' ').slice(0, 100);
4662
+ if (strippedLine === normalized) return i + 1;
4663
+ if (strippedLine.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
4664
+ if (normalized.includes(strippedLine.slice(0, 30)) && strippedLine.length > 5) return i + 1;
4044
4665
  }
4045
4666
  return -1;
4046
4667
  }
@@ -4174,21 +4795,33 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
4174
4795
 
4175
4796
  // Click on block elements
4176
4797
  preview.addEventListener('click', (e) => {
4177
- // Handle image clicks
4798
+ // Handle image clicks - always select, even if modal is showing
4178
4799
  if (e.target.tagName === 'IMG') {
4179
- if (!e.defaultPrevented) {
4180
- const line = findImageSourceLine(e.target.src);
4800
+ const line = findImageSourceLine(e.target.src);
4801
+ if (line > 0) {
4802
+ selectSourceRange(line);
4803
+ }
4804
+ return;
4805
+ }
4806
+
4807
+ // Handle links - sync to source but let link work normally
4808
+ const link = e.target.closest('a');
4809
+ if (link) {
4810
+ // Find the parent block element containing this link
4811
+ const parentBlock = link.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
4812
+ if (parentBlock) {
4813
+ const isTableCell = parentBlock.tagName === 'TD' || parentBlock.tagName === 'TH';
4814
+ const line = isTableCell ? findTableSourceLine(parentBlock.textContent) : findSourceLine(parentBlock.textContent);
4181
4815
  if (line > 0) {
4182
- e.preventDefault();
4183
- e.stopPropagation();
4184
4816
  selectSourceRange(line);
4185
4817
  }
4186
4818
  }
4819
+ // Let the link open naturally (target="_blank" is set by marked)
4187
4820
  return;
4188
4821
  }
4189
4822
 
4190
- // Ignore clicks on links, mermaid, video overlay
4191
- if (e.target.closest('a, .mermaid-container, .video-fullscreen-overlay')) return;
4823
+ // Ignore clicks on mermaid, video overlay
4824
+ if (e.target.closest('.mermaid-container, .video-fullscreen-overlay')) return;
4192
4825
 
4193
4826
  // Handle code blocks - select entire block
4194
4827
  const pre = e.target.closest('pre');
@@ -4236,6 +4869,227 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
4236
4869
  }, 10);
4237
4870
  });
4238
4871
  })();
4872
+
4873
+ // --- Reviw Questions Modal ---
4874
+ (function initReviwQuestions() {
4875
+ if (MODE !== 'markdown') return;
4876
+ if (!REVIW_QUESTIONS || REVIW_QUESTIONS.length === 0) return;
4877
+
4878
+ const overlay = document.getElementById('reviw-questions-overlay');
4879
+ const modal = document.getElementById('reviw-questions-modal');
4880
+ const body = document.getElementById('reviw-questions-body');
4881
+ const closeBtn = document.getElementById('reviw-questions-close');
4882
+ const laterBtn = document.getElementById('reviw-questions-later');
4883
+ const countSpan = document.getElementById('reviw-questions-count');
4884
+ const bar = document.getElementById('reviw-questions-bar');
4885
+ const barMessage = document.getElementById('reviw-questions-bar-message');
4886
+ const barCount = document.getElementById('reviw-questions-bar-count');
4887
+ const barOpenBtn = document.getElementById('reviw-questions-bar-open');
4888
+
4889
+ if (!overlay || !modal || !body) return;
4890
+
4891
+ // Store answers locally
4892
+ const answers = {};
4893
+ REVIW_QUESTIONS.forEach(q => {
4894
+ answers[q.id] = { selected: '', text: '' };
4895
+ });
4896
+
4897
+ // Count unresolved questions
4898
+ const unresolvedQuestions = REVIW_QUESTIONS.filter(q => !q.resolved);
4899
+ const resolvedQuestions = REVIW_QUESTIONS.filter(q => q.resolved);
4900
+
4901
+ function getUnansweredCount() {
4902
+ // Count questions that have no answer (no selection and no text)
4903
+ return unresolvedQuestions.filter(q => {
4904
+ const a = answers[q.id];
4905
+ return !a.selected && !a.text.trim();
4906
+ }).length;
4907
+ }
4908
+
4909
+ function updateCounts() {
4910
+ const unansweredCount = getUnansweredCount();
4911
+ if (unansweredCount > 0) {
4912
+ countSpan.textContent = '(' + unansweredCount + '\u4ef6\u672a\u56de\u7b54)';
4913
+ barMessage.innerHTML = '\ud83d\udccb \u672a\u56de\u7b54\u306e\u8cea\u554f\u304c<span id="reviw-questions-bar-count">' + unansweredCount + '</span>\u4ef6\u3042\u308a\u307e\u3059';
4914
+ laterBtn.textContent = '\u5f8c\u3067\u56de\u7b54\u3059\u308b';
4915
+ } else {
4916
+ countSpan.textContent = '(\u5168\u3066\u56de\u7b54\u6e08\u307f)';
4917
+ barMessage.innerHTML = '\u2705 \u5168\u3066\u306e\u8cea\u554f\u306b\u56de\u7b54\u3057\u307e\u3057\u305f';
4918
+ laterBtn.textContent = '\u9589\u3058\u308b';
4919
+ }
4920
+ }
4921
+
4922
+ function checkAllAnswered() {
4923
+ if (getUnansweredCount() === 0) {
4924
+ // All answered - close modal but keep bar visible
4925
+ setTimeout(() => {
4926
+ overlay.classList.remove('visible');
4927
+ // Keep bar visible with different message
4928
+ bar.classList.add('visible');
4929
+ document.body.classList.add('has-questions-bar');
4930
+ }, 500);
4931
+ }
4932
+ }
4933
+
4934
+ function renderQuestions() {
4935
+ body.innerHTML = '';
4936
+
4937
+ // Render unresolved questions
4938
+ unresolvedQuestions.forEach((q, idx) => {
4939
+ const item = document.createElement('div');
4940
+ item.className = 'reviw-question-item';
4941
+ item.dataset.id = q.id;
4942
+
4943
+ let optionsHtml = '';
4944
+ if (q.options && q.options.length > 0) {
4945
+ optionsHtml = '<div class="reviw-question-options">' +
4946
+ q.options.map(opt =>
4947
+ '<button class="reviw-question-option" data-value="' + escapeAttr(opt) + '">' + escapeHtml(opt) + '</button>'
4948
+ ).join('') +
4949
+ '</div>';
4950
+ }
4951
+
4952
+ const isOkOnly = q.options && q.options.length === 1 && q.options[0] === 'OK';
4953
+
4954
+ item.innerHTML =
4955
+ '<div class="reviw-question-text">Q' + (idx + 1) + '. ' + escapeHtml(q.question) + '<span class="reviw-check-mark"></span></div>' +
4956
+ optionsHtml +
4957
+ (isOkOnly ? '' : '<textarea class="reviw-question-input" placeholder="\u30c6\u30ad\u30b9\u30c8\u3067\u56de\u7b54\u30fb\u88dc\u8db3..."></textarea>');
4958
+
4959
+ body.appendChild(item);
4960
+
4961
+ // Check mark element
4962
+ const checkMark = item.querySelector('.reviw-check-mark');
4963
+
4964
+ function updateCheckMark() {
4965
+ const answer = answers[q.id];
4966
+ const hasAnswer = answer.selected || answer.text.trim();
4967
+ if (hasAnswer) {
4968
+ checkMark.textContent = ' \u2713';
4969
+ item.classList.add('answered');
4970
+ } else {
4971
+ checkMark.textContent = '';
4972
+ item.classList.remove('answered');
4973
+ }
4974
+ }
4975
+
4976
+ // Option click handlers - always toggle
4977
+ const optionBtns = item.querySelectorAll('.reviw-question-option');
4978
+ optionBtns.forEach(btn => {
4979
+ btn.addEventListener('click', () => {
4980
+ const wasSelected = btn.classList.contains('selected');
4981
+ optionBtns.forEach(b => b.classList.remove('selected'));
4982
+ if (!wasSelected) {
4983
+ btn.classList.add('selected');
4984
+ answers[q.id].selected = btn.dataset.value;
4985
+ } else {
4986
+ answers[q.id].selected = '';
4987
+ }
4988
+ updateCheckMark();
4989
+ updateCounts();
4990
+ checkAllAnswered();
4991
+ });
4992
+ });
4993
+
4994
+ // Text input handler
4995
+ const textarea = item.querySelector('.reviw-question-input');
4996
+ if (textarea) {
4997
+ textarea.addEventListener('input', () => {
4998
+ answers[q.id].text = textarea.value;
4999
+ updateCheckMark();
5000
+ updateCounts();
5001
+ checkAllAnswered();
5002
+ });
5003
+ }
5004
+
5005
+ updateCheckMark();
5006
+ });
5007
+
5008
+ // Render resolved questions (collapsed)
5009
+ if (resolvedQuestions.length > 0) {
5010
+ const section = document.createElement('div');
5011
+ section.className = 'reviw-resolved-section';
5012
+ section.innerHTML =
5013
+ '<button class="reviw-resolved-toggle">' +
5014
+ '<span class="arrow">\u25b6</span> \u89e3\u6c7a\u6e08\u307f (' + resolvedQuestions.length + '\u4ef6)' +
5015
+ '</button>' +
5016
+ '<div class="reviw-resolved-list">' +
5017
+ resolvedQuestions.map(q =>
5018
+ '<div class="reviw-resolved-item">' +
5019
+ '<div class="reviw-resolved-q">' + escapeHtml(q.question) + '</div>' +
5020
+ '<div class="reviw-resolved-a">\u2192 ' + escapeHtml(q.answer || '(no answer)') + '</div>' +
5021
+ '</div>'
5022
+ ).join('') +
5023
+ '</div>';
5024
+ body.appendChild(section);
5025
+
5026
+ const toggle = section.querySelector('.reviw-resolved-toggle');
5027
+ const list = section.querySelector('.reviw-resolved-list');
5028
+ toggle.addEventListener('click', () => {
5029
+ toggle.classList.toggle('open');
5030
+ list.classList.toggle('visible');
5031
+ });
5032
+ }
5033
+ }
5034
+
5035
+ function escapeHtml(str) {
5036
+ return String(str)
5037
+ .replace(/&/g, '&amp;')
5038
+ .replace(/</g, '&lt;')
5039
+ .replace(/>/g, '&gt;');
5040
+ }
5041
+
5042
+ function escapeAttr(str) {
5043
+ return String(str)
5044
+ .replace(/&/g, '&amp;')
5045
+ .replace(/"/g, '&quot;');
5046
+ }
5047
+
5048
+ function openModal() {
5049
+ overlay.classList.add('visible');
5050
+ bar.classList.remove('visible');
5051
+ document.body.classList.remove('has-questions-bar');
5052
+ }
5053
+
5054
+ function closeModal(allAnswered) {
5055
+ overlay.classList.remove('visible');
5056
+ const unansweredCount = getUnansweredCount();
5057
+ if (unansweredCount > 0 && !allAnswered) {
5058
+ bar.classList.add('visible');
5059
+ document.body.classList.add('has-questions-bar');
5060
+ } else {
5061
+ bar.classList.remove('visible');
5062
+ document.body.classList.remove('has-questions-bar');
5063
+ }
5064
+ }
5065
+
5066
+ // Event listeners
5067
+ closeBtn.addEventListener('click', () => closeModal(false));
5068
+ laterBtn.addEventListener('click', () => closeModal(false));
5069
+ barOpenBtn.addEventListener('click', openModal);
5070
+
5071
+ overlay.addEventListener('click', (e) => {
5072
+ if (e.target === overlay) closeModal(false);
5073
+ });
5074
+
5075
+ document.addEventListener('keydown', (e) => {
5076
+ if (e.key === 'Escape' && overlay.classList.contains('visible')) {
5077
+ closeModal(false);
5078
+ }
5079
+ });
5080
+
5081
+ // Expose answers for submit (only answered ones)
5082
+ window.REVIW_ANSWERS = answers;
5083
+
5084
+ // Initialize
5085
+ updateCounts();
5086
+ renderQuestions();
5087
+
5088
+ // Show modal on load if there are unresolved questions
5089
+ if (unresolvedQuestions.length > 0) {
5090
+ setTimeout(() => openModal(), 300);
5091
+ }
5092
+ })();
4239
5093
  </script>
4240
5094
  </body>
4241
5095
  </html>`;
@@ -4246,8 +5100,8 @@ function buildHtml(filePath) {
4246
5100
  if (data.mode === "diff") {
4247
5101
  return diffHtmlTemplate(data);
4248
5102
  }
4249
- const { rows, cols, title, mode, preview } = data;
4250
- return htmlTemplate(rows, cols, title, mode, preview);
5103
+ const { rows, cols, projectRoot, relativePath, mode, preview, reviwQuestions } = data;
5104
+ return htmlTemplate(rows, cols, projectRoot, relativePath, mode, preview, reviwQuestions);
4251
5105
  }
4252
5106
 
4253
5107
  // --- HTTP Server -----------------------------------------------------------
@@ -4279,6 +5133,20 @@ function outputAllResults() {
4279
5133
  const yamlOut = yaml.dump(combined, { noRefs: true, lineWidth: 120 });
4280
5134
  console.log(yamlOut.trim());
4281
5135
  }
5136
+
5137
+ // Output answered questions if any
5138
+ const allAnswers = [];
5139
+ for (const result of allResults) {
5140
+ if (result.reviwAnswers && result.reviwAnswers.length > 0) {
5141
+ allAnswers.push(...result.reviwAnswers);
5142
+ }
5143
+ }
5144
+ if (allAnswers.length > 0) {
5145
+ console.log("\n[REVIW_ANSWERS]");
5146
+ const answersYaml = yaml.dump(allAnswers, { noRefs: true, lineWidth: 120 });
5147
+ console.log(answersYaml.trim());
5148
+ console.log("[/REVIW_ANSWERS]");
5149
+ }
4282
5150
  }
4283
5151
 
4284
5152
  function checkAllDone() {
@@ -4525,7 +5393,14 @@ function createFileServer(filePath, fileIndex = 0) {
4525
5393
  const delay = fileIndex * 300;
4526
5394
  setTimeout(() => {
4527
5395
  try {
4528
- spawn(opener, [url], { stdio: "ignore", detached: true });
5396
+ const child = spawn(opener, [url], { stdio: "ignore", detached: true });
5397
+ child.on('error', (err) => {
5398
+ console.warn(
5399
+ "Failed to open browser automatically. Please open this URL manually:",
5400
+ url,
5401
+ );
5402
+ });
5403
+ child.unref();
4529
5404
  } catch (err) {
4530
5405
  console.warn(
4531
5406
  "Failed to open browser automatically. Please open this URL manually:",
@@ -4693,7 +5568,14 @@ function createDiffServer(diffContent) {
4693
5568
  ? "start"
4694
5569
  : "xdg-open";
4695
5570
  try {
4696
- spawn(opener, [url], { stdio: "ignore", detached: true });
5571
+ const child = spawn(opener, [url], { stdio: "ignore", detached: true });
5572
+ child.on('error', (err) => {
5573
+ console.warn(
5574
+ "Failed to open browser automatically. Please open this URL manually:",
5575
+ url,
5576
+ );
5577
+ });
5578
+ child.unref();
4697
5579
  } catch (err) {
4698
5580
  console.warn(
4699
5581
  "Failed to open browser automatically. Please open this URL manually:",
@@ -4709,7 +5591,18 @@ function createDiffServer(diffContent) {
4709
5591
  });
4710
5592
  }
4711
5593
 
4712
- // Main entry point
5594
+ // Main entry point - only run when executed directly (not when required for testing)
5595
+ if (require.main === module) {
5596
+ // Parse CLI arguments and apply configuration
5597
+ const { config, filePaths } = parseCliArgs(process.argv);
5598
+ basePort = config.basePort;
5599
+ encodingOpt = config.encodingOpt;
5600
+ noOpen = config.noOpen;
5601
+ nextPort = config.basePort; // Update nextPort to match configured basePort
5602
+
5603
+ // Validate and resolve file paths
5604
+ resolvedPaths = validateAndResolvePaths(filePaths);
5605
+
4713
5606
  (async () => {
4714
5607
  // Check for stdin input first
4715
5608
  const stdinData = await checkStdin();
@@ -4778,3 +5671,9 @@ function createDiffServer(diffContent) {
4778
5671
  }
4779
5672
  }
4780
5673
  })();
5674
+ }
5675
+
5676
+ // Export parser functions for testing
5677
+ if (typeof module !== "undefined" && module.exports) {
5678
+ module.exports = { parseDiff, parseCsv, DEFAULT_CONFIG };
5679
+ }