reviw 0.10.5 → 0.10.7

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 (2) hide show
  1. package/cli.cjs +382 -58
  2. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -20,17 +20,157 @@ const iconv = require("iconv-lite");
20
20
  const marked = require("marked");
21
21
  const yaml = require("js-yaml");
22
22
 
23
+ // --- XSS Protection for marked (Whitelist approach) ---
24
+ // 許可タグリスト(Markdown由来の安全なタグのみ)
25
+ const allowedTags = new Set([
26
+ 'p', 'br', 'hr',
27
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
28
+ 'ul', 'ol', 'li',
29
+ 'blockquote', 'pre', 'code',
30
+ 'em', 'strong', 'del', 's',
31
+ 'a', 'img',
32
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
33
+ 'div', 'span', // Markdown拡張用
34
+ ]);
35
+
36
+ // 許可属性リスト(タグごとに定義)
37
+ const allowedAttributes = {
38
+ 'a': ['href', 'title', 'target', 'rel'],
39
+ 'img': ['src', 'alt', 'title', 'width', 'height'],
40
+ 'code': ['class'], // 言語ハイライト用
41
+ 'pre': ['class'],
42
+ 'div': ['class'],
43
+ 'span': ['class'],
44
+ 'th': ['align'],
45
+ 'td': ['align'],
46
+ };
47
+
48
+ // HTMLエスケープ関数(XSS対策用)
49
+ function escapeHtmlForXss(html) {
50
+ return html
51
+ .replace(/&/g, "&")
52
+ .replace(/</g, "&lt;")
53
+ .replace(/>/g, "&gt;")
54
+ .replace(/"/g, "&quot;")
55
+ .replace(/'/g, "&#39;");
56
+ }
57
+
58
+ // href/src属性のURLバリデーション
59
+ function isSafeUrl(url) {
60
+ if (!url) return true;
61
+ // 空白・制御文字を除去して正規化
62
+ var normalized = url.toLowerCase().replace(/[\s\x00-\x1f]/g, '');
63
+ // HTMLエンティティのデコード(&#x0a; &#10; など)
64
+ var decoded = normalized.replace(/&#x?[0-9a-f]+;?/gi, '');
65
+ if (decoded.startsWith('javascript:')) return false;
66
+ if (decoded.startsWith('vbscript:')) return false;
67
+ if (decoded.startsWith('data:') && !decoded.startsWith('data:image/')) return false;
68
+ return true;
69
+ }
70
+
71
+ // HTML文字列をサニタイズ(ホワイトリストに含まれないタグ/属性を除去)
72
+ function sanitizeHtml(html) {
73
+ // より堅牢なタグマッチング:属性値内の < > を考慮
74
+ // 引用符で囲まれた属性値を正しく処理するパターン
75
+ var tagPattern = /<\/?([a-z][a-z0-9]*)((?:\s+[a-z][a-z0-9-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>"']*))?)*)\s*\/?>/gi;
76
+
77
+ return html.replace(tagPattern, function(match, tag, attrsStr) {
78
+ var tagLower = tag.toLowerCase();
79
+
80
+ // 許可されていないタグはエスケープ
81
+ if (!allowedTags.has(tagLower)) {
82
+ return escapeHtmlForXss(match);
83
+ }
84
+
85
+ // 終了タグはそのまま
86
+ if (match.startsWith('</')) {
87
+ return '</' + tagLower + '>';
88
+ }
89
+
90
+ // 属性をフィルタリング
91
+ var allowed = allowedAttributes[tagLower] || [];
92
+ var safeAttrs = [];
93
+
94
+ // 属性を解析(引用符で囲まれた値を正しく処理)
95
+ var attrRegex = /([a-z][a-z0-9-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']*))/gi;
96
+ var attrMatch;
97
+ while ((attrMatch = attrRegex.exec(attrsStr)) !== null) {
98
+ var attrName = attrMatch[1].toLowerCase();
99
+ var attrValue = attrMatch[2] !== undefined ? attrMatch[2] :
100
+ attrMatch[3] !== undefined ? attrMatch[3] :
101
+ attrMatch[4] || '';
102
+
103
+ // on*イベントハンドラは常に拒否
104
+ if (attrName.startsWith('on')) continue;
105
+
106
+ // 許可属性のみ
107
+ if (!allowed.includes(attrName)) continue;
108
+
109
+ // href/srcのURL検証
110
+ if ((attrName === 'href' || attrName === 'src') && !isSafeUrl(attrValue)) {
111
+ continue;
112
+ }
113
+
114
+ safeAttrs.push(attrName + '="' + attrValue.replace(/"/g, '&quot;') + '"');
115
+ }
116
+
117
+ var finalAttrs = safeAttrs.length > 0 ? ' ' + safeAttrs.join(' ') : '';
118
+ return '<' + tagLower + finalAttrs + '>';
119
+ });
120
+ }
121
+
122
+ marked.use({
123
+ renderer: {
124
+ // 生HTMLブロックをサニタイズ
125
+ html: function(token) {
126
+ var text = token.raw || token.text || token;
127
+ return sanitizeHtml(text);
128
+ },
129
+ // リンクに安全なURL検証を追加(別タブで開く)
130
+ link: function(href, title, text) {
131
+ href = href || "";
132
+ title = title || "";
133
+ text = text || "";
134
+ if (!isSafeUrl(href)) {
135
+ // 危険なURLはプレーンテキストとして表示
136
+ return escapeHtmlForXss(text);
137
+ }
138
+ var titleAttr = title ? ' title="' + escapeHtmlForXss(title) + '"' : "";
139
+ return '<a href="' + escapeHtmlForXss(href) + '"' + titleAttr + ' target="_blank" rel="noopener noreferrer">' + text + '</a>';
140
+ },
141
+ // 画像にも安全なURL検証を追加
142
+ image: function(href, title, text) {
143
+ href = href || "";
144
+ title = title || "";
145
+ text = text || "";
146
+ if (!isSafeUrl(href)) {
147
+ return escapeHtmlForXss(text || "image");
148
+ }
149
+ var titleAttr = title ? ' title="' + escapeHtmlForXss(title) + '"' : "";
150
+ var altAttr = text ? ' alt="' + escapeHtmlForXss(text) + '"' : "";
151
+ return '<img src="' + escapeHtmlForXss(href) + '"' + altAttr + titleAttr + '>';
152
+ }
153
+ }
154
+ });
155
+
23
156
  // --- CLI arguments ---------------------------------------------------------
24
157
  const VERSION = require("./package.json").version;
25
- const args = process.argv.slice(2);
26
158
 
27
- const filePaths = [];
28
- let basePort = 4989;
29
- let encodingOpt = null;
30
- let noOpen = false;
159
+ // ===== CLI設定のデフォルト値(import時に使用) =====
160
+ const DEFAULT_CONFIG = {
161
+ basePort: 4989,
162
+ encodingOpt: null,
163
+ noOpen: false,
164
+ };
165
+
166
+ // ===== グローバル設定変数(デフォルト値で初期化、require.main時に更新) =====
167
+ let basePort = DEFAULT_CONFIG.basePort;
168
+ let encodingOpt = DEFAULT_CONFIG.encodingOpt;
169
+ let noOpen = DEFAULT_CONFIG.noOpen;
31
170
  let stdinMode = false;
32
171
  let diffMode = false;
33
172
  let stdinContent = null;
173
+ let resolvedPaths = []; // ファイルパス(require.main時に設定)
34
174
 
35
175
  function showHelp() {
36
176
  console.log(`reviw v${VERSION} - Lightweight file reviewer with in-browser comments
@@ -75,25 +215,55 @@ function showVersion() {
75
215
  console.log(VERSION);
76
216
  }
77
217
 
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);
218
+ // ===== CLI引数パース関数(require.main時のみ呼ばれる) =====
219
+ function parseCliArgs(argv) {
220
+ const args = argv.slice(2);
221
+ const config = { ...DEFAULT_CONFIG };
222
+ const filePaths = [];
223
+
224
+ for (let i = 0; i < args.length; i += 1) {
225
+ const arg = args[i];
226
+ if (arg === "--port" && args[i + 1]) {
227
+ config.basePort = Number(args[i + 1]);
228
+ i += 1;
229
+ } else if ((arg === "--encoding" || arg === "-e") && args[i + 1]) {
230
+ config.encodingOpt = args[i + 1];
231
+ i += 1;
232
+ } else if (arg === "--no-open") {
233
+ config.noOpen = true;
234
+ } else if (arg === "--help" || arg === "-h") {
235
+ showHelp();
236
+ process.exit(0);
237
+ } else if (arg === "--version" || arg === "-v") {
238
+ showVersion();
239
+ process.exit(0);
240
+ } else if (!arg.startsWith("-")) {
241
+ filePaths.push(arg);
242
+ }
243
+ }
244
+
245
+ return { config, filePaths };
246
+ }
247
+
248
+ // ===== ファイルパス検証・解決関数(require.main時のみ呼ばれる) =====
249
+ function validateAndResolvePaths(filePaths) {
250
+ const resolved = [];
251
+ for (const fp of filePaths) {
252
+ const resolvedPath = path.resolve(fp);
253
+ if (!fs.existsSync(resolvedPath)) {
254
+ console.error(`File not found: ${resolvedPath}`);
255
+ process.exit(1);
256
+ }
257
+ const stat = fs.statSync(resolvedPath);
258
+ if (stat.isDirectory()) {
259
+ console.error(`Cannot open directory: ${resolvedPath}`);
260
+ console.error(`Usage: reviw <file> [file2...]`);
261
+ console.error(`Please specify a file, not a directory.`);
262
+ process.exit(1);
263
+ }
264
+ resolved.push(resolvedPath);
96
265
  }
266
+ return resolved;
97
267
  }
98
268
 
99
269
  // Check if stdin has data (pipe mode)
@@ -148,24 +318,6 @@ function runGitDiff() {
148
318
  });
149
319
  }
150
320
 
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
321
  // --- Diff parsing -----------------------------------------------------------
170
322
  function parseDiff(diffText) {
171
323
  const files = [];
@@ -1387,7 +1539,19 @@ function diffHtmlTemplate(diffData) {
1387
1539
  globalComment = globalCommentInput.value;
1388
1540
  hideSubmitModal();
1389
1541
  sendAndExit('button');
1390
- setTimeout(() => window.close(), 200);
1542
+ // Try to close window; if it fails (browser security), show completion message
1543
+ setTimeout(() => {
1544
+ window.close();
1545
+ // If window.close() didn't work, show a completion message
1546
+ setTimeout(() => {
1547
+ document.body.innerHTML = \`
1548
+ <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;">
1549
+ <h1 style="font-size:2rem;margin-bottom:1rem;">✅ Review Submitted</h1>
1550
+ <p style="color:var(--muted,#888);">You can close this tab now.</p>
1551
+ </div>
1552
+ \`;
1553
+ }, 100);
1554
+ }, 200);
1391
1555
  }
1392
1556
  document.getElementById('modal-submit').addEventListener('click', doSubmit);
1393
1557
  globalCommentInput.addEventListener('keydown', e => {
@@ -1470,6 +1634,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1470
1634
  --hover-bg: rgba(96,165,250,0.08);
1471
1635
  --shadow-color: rgba(0,0,0,0.35);
1472
1636
  --code-bg: #1e293b;
1637
+ --error: #dc3545;
1473
1638
  }
1474
1639
  /* Light theme */
1475
1640
  [data-theme="light"] {
@@ -1496,6 +1661,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1496
1661
  --hover-bg: rgba(59,130,246,0.06);
1497
1662
  --shadow-color: rgba(0,0,0,0.1);
1498
1663
  --code-bg: #f1f5f9;
1664
+ --error: #dc3545;
1499
1665
  }
1500
1666
  * { box-sizing: border-box; }
1501
1667
  body {
@@ -1973,9 +2139,6 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1973
2139
  text-align: left;
1974
2140
  border-bottom: 1px solid var(--border);
1975
2141
  }
1976
- .md-preview table:not(.frontmatter-table table) th {
1977
- background: rgba(255,255,255,0.05);
1978
- }
1979
2142
  .md-preview table:not(.frontmatter-table table) th {
1980
2143
  background: var(--panel);
1981
2144
  font-weight: 600;
@@ -2305,13 +2468,46 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2305
2468
  .fullscreen-content .mermaid svg {
2306
2469
  display: block;
2307
2470
  }
2471
+ /* Minimap */
2472
+ .minimap {
2473
+ position: absolute;
2474
+ top: 70px;
2475
+ right: 20px;
2476
+ width: 200px;
2477
+ height: 150px;
2478
+ background: var(--panel-alpha);
2479
+ border: 1px solid var(--border);
2480
+ border-radius: 8px;
2481
+ overflow: hidden;
2482
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
2483
+ }
2484
+ .minimap-content {
2485
+ width: 100%;
2486
+ height: 100%;
2487
+ display: flex;
2488
+ align-items: center;
2489
+ justify-content: center;
2490
+ padding: 8px;
2491
+ }
2492
+ .minimap-content svg {
2493
+ max-width: 100%;
2494
+ max-height: 100%;
2495
+ opacity: 0.6;
2496
+ }
2497
+ .minimap-viewport {
2498
+ position: absolute;
2499
+ border: 2px solid var(--accent);
2500
+ background: rgba(102, 126, 234, 0.2);
2501
+ pointer-events: none;
2502
+ border-radius: 2px;
2503
+ }
2308
2504
  /* Error toast */
2309
2505
  .mermaid-error-toast {
2310
2506
  position: fixed;
2311
2507
  bottom: 20px;
2312
2508
  left: 50%;
2313
2509
  transform: translateX(-50%);
2314
- background: #dc3545;
2510
+ background: var(--error);
2315
2511
  color: white;
2316
2512
  padding: 12px 24px;
2317
2513
  border-radius: 8px;
@@ -2470,6 +2666,10 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2470
2666
  <div class="fullscreen-content" id="fs-content">
2471
2667
  <div class="mermaid-wrapper" id="fs-wrapper"></div>
2472
2668
  </div>
2669
+ <div class="minimap" id="fs-minimap">
2670
+ <div class="minimap-content" id="fs-minimap-content"></div>
2671
+ <div class="minimap-viewport" id="fs-minimap-viewport"></div>
2672
+ </div>
2473
2673
  </div>
2474
2674
  <div class="mermaid-error-toast" id="mermaid-error-toast"></div>
2475
2675
  <div class="copy-toast" id="copy-toast">Copied to clipboard!</div>
@@ -3388,7 +3588,19 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3388
3588
  globalComment = globalCommentInput.value;
3389
3589
  hideSubmitModal();
3390
3590
  sendAndExit('button');
3391
- setTimeout(() => window.close(), 200);
3591
+ // Try to close window; if it fails (browser security), show completion message
3592
+ setTimeout(() => {
3593
+ window.close();
3594
+ // If window.close() didn't work, show a completion message
3595
+ setTimeout(() => {
3596
+ document.body.innerHTML = \`
3597
+ <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;">
3598
+ <h1 style="font-size:2rem;margin-bottom:1rem;">✅ Review Submitted</h1>
3599
+ <p style="color:var(--muted,#888);">You can close this tab now.</p>
3600
+ </div>
3601
+ \`;
3602
+ }, 100);
3603
+ }, 200);
3392
3604
  }
3393
3605
  modalSubmit.addEventListener('click', doSubmit);
3394
3606
  globalCommentInput.addEventListener('keydown', (e) => {
@@ -3578,12 +3790,15 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3578
3790
  const fsWrapper = document.getElementById('fs-wrapper');
3579
3791
  const fsContent = document.getElementById('fs-content');
3580
3792
  const fsZoomInfo = document.getElementById('fs-zoom-info');
3793
+ const minimapContent = document.getElementById('fs-minimap-content');
3794
+ const minimapViewport = document.getElementById('fs-minimap-viewport');
3581
3795
  let currentZoom = 1;
3582
3796
  let initialZoom = 1;
3583
3797
  let panX = 0, panY = 0;
3584
3798
  let isPanning = false;
3585
3799
  let startX, startY;
3586
3800
  let svgNaturalWidth = 0, svgNaturalHeight = 0;
3801
+ let minimapScale = 1;
3587
3802
 
3588
3803
  function openFullscreen(mermaidEl) {
3589
3804
  const svg = mermaidEl.querySelector('svg');
@@ -3592,6 +3807,11 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3592
3807
  const clonedSvg = svg.cloneNode(true);
3593
3808
  fsWrapper.appendChild(clonedSvg);
3594
3809
 
3810
+ // Setup minimap
3811
+ minimapContent.innerHTML = '';
3812
+ const minimapSvg = svg.cloneNode(true);
3813
+ minimapContent.appendChild(minimapSvg);
3814
+
3595
3815
  // Get SVG's intrinsic/natural size from viewBox or attributes
3596
3816
  const viewBox = svg.getAttribute('viewBox');
3597
3817
  let naturalWidth, naturalHeight;
@@ -3608,6 +3828,11 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3608
3828
  svgNaturalWidth = naturalWidth;
3609
3829
  svgNaturalHeight = naturalHeight;
3610
3830
 
3831
+ // Calculate minimap scale
3832
+ const minimapMaxWidth = 184; // 200 - 16 padding
3833
+ const minimapMaxHeight = 134; // 150 - 16 padding
3834
+ minimapScale = Math.min(minimapMaxWidth / naturalWidth, minimapMaxHeight / naturalHeight);
3835
+
3611
3836
  clonedSvg.style.width = naturalWidth + 'px';
3612
3837
  clonedSvg.style.height = naturalHeight + 'px';
3613
3838
 
@@ -3628,8 +3853,11 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3628
3853
  panX = (viewportWidth - scaledWidth) / 2 + 20;
3629
3854
  panY = (viewportHeight - scaledHeight) / 2 + 60;
3630
3855
 
3631
- updateTransform();
3632
3856
  fsOverlay.classList.add('visible');
3857
+ // Wait for DOM to render before calculating minimap position
3858
+ requestAnimationFrame(() => {
3859
+ updateTransform();
3860
+ });
3633
3861
  }
3634
3862
 
3635
3863
  function closeFullscreen() {
@@ -3639,6 +3867,71 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3639
3867
  function updateTransform() {
3640
3868
  fsWrapper.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + currentZoom + ')';
3641
3869
  fsZoomInfo.textContent = Math.round(currentZoom * 100) + '%';
3870
+ updateMinimap();
3871
+ }
3872
+
3873
+ function updateMinimap() {
3874
+ if (!svgNaturalWidth || !svgNaturalHeight) return;
3875
+
3876
+ const viewportWidth = fsContent.clientWidth;
3877
+ const viewportHeight = fsContent.clientHeight;
3878
+
3879
+ // Minimap container dimensions (inner area)
3880
+ const mmWidth = 184; // 200 - 16 padding
3881
+ const mmHeight = 134; // 150 - 16 padding
3882
+ const mmPadding = 8;
3883
+
3884
+ // SVG thumbnail size in minimap (scaled to fit)
3885
+ const mmSvgWidth = svgNaturalWidth * minimapScale;
3886
+ const mmSvgHeight = svgNaturalHeight * minimapScale;
3887
+ // SVG thumbnail position (centered in minimap)
3888
+ const mmSvgLeft = (mmWidth - mmSvgWidth) / 2 + mmPadding;
3889
+ const mmSvgTop = (mmHeight - mmSvgHeight) / 2 + mmPadding;
3890
+
3891
+ // Calculate which part of the SVG is visible in the viewport
3892
+ // transform: translate(panX, panY) scale(currentZoom)
3893
+ // The viewport shows SVG region starting at (-panX/zoom, -panY/zoom)
3894
+ const svgVisibleLeft = -panX / currentZoom;
3895
+ const svgVisibleTop = -panY / currentZoom;
3896
+ const svgVisibleWidth = viewportWidth / currentZoom;
3897
+ const svgVisibleHeight = viewportHeight / currentZoom;
3898
+
3899
+ // Convert to minimap coordinates
3900
+ let vpLeft = mmSvgLeft + svgVisibleLeft * minimapScale;
3901
+ let vpTop = mmSvgTop + svgVisibleTop * minimapScale;
3902
+ let vpWidth = svgVisibleWidth * minimapScale;
3903
+ let vpHeight = svgVisibleHeight * minimapScale;
3904
+
3905
+ // Clamp to minimap bounds (the viewport rect should stay within minimap)
3906
+ const mmLeft = mmPadding;
3907
+ const mmTop = mmPadding;
3908
+ const mmRight = mmWidth + mmPadding;
3909
+ const mmBottom = mmHeight + mmPadding;
3910
+
3911
+ // Adjust if viewport extends beyond minimap bounds
3912
+ if (vpLeft < mmLeft) {
3913
+ vpWidth -= (mmLeft - vpLeft);
3914
+ vpLeft = mmLeft;
3915
+ }
3916
+ if (vpTop < mmTop) {
3917
+ vpHeight -= (mmTop - vpTop);
3918
+ vpTop = mmTop;
3919
+ }
3920
+ if (vpLeft + vpWidth > mmRight) {
3921
+ vpWidth = mmRight - vpLeft;
3922
+ }
3923
+ if (vpTop + vpHeight > mmBottom) {
3924
+ vpHeight = mmBottom - vpTop;
3925
+ }
3926
+
3927
+ // Ensure minimum size and positive dimensions
3928
+ vpWidth = Math.max(20, vpWidth);
3929
+ vpHeight = Math.max(15, vpHeight);
3930
+
3931
+ minimapViewport.style.left = vpLeft + 'px';
3932
+ minimapViewport.style.top = vpTop + 'px';
3933
+ minimapViewport.style.width = vpWidth + 'px';
3934
+ minimapViewport.style.height = vpHeight + 'px';
3642
3935
  }
3643
3936
 
3644
3937
  // Use multiplicative zoom for consistent behavior
@@ -3916,13 +4209,11 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3916
4209
  }
3917
4210
  }
3918
4211
 
3919
- // Check for markdown list items: strip list markers and formatting
3920
- if (lineText.match(/^[-*+]\\s|^\\d+\\.\\s/)) {
3921
- const strippedLine = stripMarkdown(lineText).replace(/\\s+/g, ' ').slice(0, 100);
3922
- if (strippedLine === normalized) return i + 1;
3923
- if (strippedLine.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
3924
- if (normalized.includes(strippedLine.slice(0, 30)) && strippedLine.length > 5) return i + 1;
3925
- }
4212
+ // Try stripping all markdown formatting (links, bold, italic, etc.)
4213
+ const strippedLine = stripMarkdown(lineText).replace(/\\s+/g, ' ').slice(0, 100);
4214
+ if (strippedLine === normalized) return i + 1;
4215
+ if (strippedLine.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
4216
+ if (normalized.includes(strippedLine.slice(0, 30)) && strippedLine.length > 5) return i + 1;
3926
4217
  }
3927
4218
  return -1;
3928
4219
  }
@@ -4069,8 +4360,24 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
4069
4360
  return;
4070
4361
  }
4071
4362
 
4072
- // Ignore clicks on links, mermaid, video overlay
4073
- if (e.target.closest('a, .mermaid-container, .video-fullscreen-overlay')) return;
4363
+ // Handle links - sync to source but let link work normally
4364
+ const link = e.target.closest('a');
4365
+ if (link) {
4366
+ // Find the parent block element containing this link
4367
+ const parentBlock = link.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
4368
+ if (parentBlock) {
4369
+ const isTableCell = parentBlock.tagName === 'TD' || parentBlock.tagName === 'TH';
4370
+ const line = isTableCell ? findTableSourceLine(parentBlock.textContent) : findSourceLine(parentBlock.textContent);
4371
+ if (line > 0) {
4372
+ selectSourceRange(line);
4373
+ }
4374
+ }
4375
+ // Let the link open naturally (target="_blank" is set by marked)
4376
+ return;
4377
+ }
4378
+
4379
+ // Ignore clicks on mermaid, video overlay
4380
+ if (e.target.closest('.mermaid-container, .video-fullscreen-overlay')) return;
4074
4381
 
4075
4382
  // Handle code blocks - select entire block
4076
4383
  const pre = e.target.closest('pre');
@@ -4591,7 +4898,18 @@ function createDiffServer(diffContent) {
4591
4898
  });
4592
4899
  }
4593
4900
 
4594
- // Main entry point
4901
+ // Main entry point - only run when executed directly (not when required for testing)
4902
+ if (require.main === module) {
4903
+ // Parse CLI arguments and apply configuration
4904
+ const { config, filePaths } = parseCliArgs(process.argv);
4905
+ basePort = config.basePort;
4906
+ encodingOpt = config.encodingOpt;
4907
+ noOpen = config.noOpen;
4908
+ nextPort = config.basePort; // Update nextPort to match configured basePort
4909
+
4910
+ // Validate and resolve file paths
4911
+ resolvedPaths = validateAndResolvePaths(filePaths);
4912
+
4595
4913
  (async () => {
4596
4914
  // Check for stdin input first
4597
4915
  const stdinData = await checkStdin();
@@ -4660,3 +4978,9 @@ function createDiffServer(diffContent) {
4660
4978
  }
4661
4979
  }
4662
4980
  })();
4981
+ }
4982
+
4983
+ // Export parser functions for testing
4984
+ if (typeof module !== "undefined" && module.exports) {
4985
+ module.exports = { parseDiff, parseCsv, DEFAULT_CONFIG };
4986
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.10.5",
3
+ "version": "0.10.7",
4
4
  "description": "Lightweight file reviewer with in-browser comments for CSV, TSV, Markdown, and Git diffs.",
5
5
  "type": "module",
6
6
  "bin": {