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.
- package/cli.cjs +263 -57
- 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, "<")
|
|
53
|
+
.replace(/>/g, ">")
|
|
54
|
+
.replace(/"/g, """)
|
|
55
|
+
.replace(/'/g, "'");
|
|
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エンティティのデコード(
 など)
|
|
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, '"') + '"');
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
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
|
-
//
|
|
4191
|
-
|
|
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
|
+
}
|