reviw 0.10.6 → 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 +263 -57
  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;
@@ -2344,7 +2507,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2344
2507
  bottom: 20px;
2345
2508
  left: 50%;
2346
2509
  transform: translateX(-50%);
2347
- background: #dc3545;
2510
+ background: var(--error);
2348
2511
  color: white;
2349
2512
  padding: 12px 24px;
2350
2513
  border-radius: 8px;
@@ -3425,7 +3588,19 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3425
3588
  globalComment = globalCommentInput.value;
3426
3589
  hideSubmitModal();
3427
3590
  sendAndExit('button');
3428
- 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);
3429
3604
  }
3430
3605
  modalSubmit.addEventListener('click', doSubmit);
3431
3606
  globalCommentInput.addEventListener('keydown', (e) => {
@@ -4034,13 +4209,11 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
4034
4209
  }
4035
4210
  }
4036
4211
 
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
- }
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;
4044
4217
  }
4045
4218
  return -1;
4046
4219
  }
@@ -4187,8 +4360,24 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
4187
4360
  return;
4188
4361
  }
4189
4362
 
4190
- // Ignore clicks on links, mermaid, video overlay
4191
- 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;
4192
4381
 
4193
4382
  // Handle code blocks - select entire block
4194
4383
  const pre = e.target.closest('pre');
@@ -4709,7 +4898,18 @@ function createDiffServer(diffContent) {
4709
4898
  });
4710
4899
  }
4711
4900
 
4712
- // 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
+
4713
4913
  (async () => {
4714
4914
  // Check for stdin input first
4715
4915
  const stdinData = await checkStdin();
@@ -4778,3 +4978,9 @@ function createDiffServer(diffContent) {
4778
4978
  }
4779
4979
  }
4780
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.6",
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": {