md-lv 1.0.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.
- package/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/mdv.js +5 -0
- package/package.json +64 -0
- package/public/favicon.svg +4 -0
- package/public/js/.gitkeep +0 -0
- package/public/js/app.js +174 -0
- package/public/js/navigation.js +201 -0
- package/public/js/search.js +286 -0
- package/public/styles/.gitkeep +0 -0
- package/public/styles/base.css +314 -0
- package/public/styles/modern.css +289 -0
- package/src/cli.js +137 -0
- package/src/index.js +15 -0
- package/src/middleware/.gitkeep +0 -0
- package/src/middleware/error.js +118 -0
- package/src/middleware/security.js +49 -0
- package/src/routes/.gitkeep +0 -0
- package/src/routes/api.js +92 -0
- package/src/routes/directory.js +110 -0
- package/src/routes/markdown.js +53 -0
- package/src/routes/raw.js +74 -0
- package/src/server.js +61 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/html.js +37 -0
- package/src/utils/icons.js +79 -0
- package/src/utils/language.js +102 -0
- package/src/utils/logger.js +81 -0
- package/src/utils/navigation.js +41 -0
- package/src/utils/path.js +86 -0
- package/src/utils/port.js +49 -0
- package/src/utils/readme.js +36 -0
- package/src/utils/template.js +35 -0
- package/templates/.gitkeep +0 -0
- package/templates/error.html +76 -0
- package/templates/page.html +60 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
markdown-viewer Modern Styles
|
|
3
|
+
Dark mode support and enhanced UI
|
|
4
|
+
========================================================================== */
|
|
5
|
+
|
|
6
|
+
/* ============================================
|
|
7
|
+
CSS Custom Properties (Light Theme Default)
|
|
8
|
+
============================================ */
|
|
9
|
+
:root {
|
|
10
|
+
--color-bg: #ffffff;
|
|
11
|
+
--color-bg-secondary: #f6f8fa;
|
|
12
|
+
--color-text: #24292e;
|
|
13
|
+
--color-text-secondary: #586069;
|
|
14
|
+
--color-text-muted: #6a737d;
|
|
15
|
+
--color-link: #0366d6;
|
|
16
|
+
--color-border: #e1e4e8;
|
|
17
|
+
--color-border-strong: #c6cbd1;
|
|
18
|
+
--color-code-bg: #f6f8fa;
|
|
19
|
+
--color-blockquote-border: #dfe2e5;
|
|
20
|
+
--color-success: #28a745;
|
|
21
|
+
--color-warning: #ffc107;
|
|
22
|
+
--color-error: #dc3545;
|
|
23
|
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
24
|
+
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
25
|
+
--transition-fast: 0.15s ease;
|
|
26
|
+
--transition-normal: 0.25s ease;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ============================================
|
|
30
|
+
Dark Theme
|
|
31
|
+
============================================ */
|
|
32
|
+
@media (prefers-color-scheme: dark) {
|
|
33
|
+
:root {
|
|
34
|
+
--color-bg: #0d1117;
|
|
35
|
+
--color-bg-secondary: #161b22;
|
|
36
|
+
--color-text: #c9d1d9;
|
|
37
|
+
--color-text-secondary: #8b949e;
|
|
38
|
+
--color-text-muted: #6e7681;
|
|
39
|
+
--color-link: #58a6ff;
|
|
40
|
+
--color-border: #30363d;
|
|
41
|
+
--color-border-strong: #484f58;
|
|
42
|
+
--color-code-bg: #161b22;
|
|
43
|
+
--color-blockquote-border: #3b434b;
|
|
44
|
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
45
|
+
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ============================================
|
|
50
|
+
Base Overrides
|
|
51
|
+
============================================ */
|
|
52
|
+
body {
|
|
53
|
+
background-color: var(--color-bg);
|
|
54
|
+
color: var(--color-text);
|
|
55
|
+
transition: background-color var(--transition-normal), color var(--transition-normal);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
a {
|
|
59
|
+
color: var(--color-link);
|
|
60
|
+
transition: color var(--transition-fast);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
a:hover {
|
|
64
|
+
color: var(--color-link);
|
|
65
|
+
opacity: 0.8;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ============================================
|
|
69
|
+
Breadcrumbs Enhancement
|
|
70
|
+
============================================ */
|
|
71
|
+
#breadcrumbs {
|
|
72
|
+
background-color: var(--color-bg-secondary);
|
|
73
|
+
padding: 12px 16px;
|
|
74
|
+
border-radius: 6px;
|
|
75
|
+
margin-bottom: 24px;
|
|
76
|
+
border: 1px solid var(--color-border);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#breadcrumbs a {
|
|
80
|
+
color: var(--color-link);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#breadcrumbs .current {
|
|
84
|
+
color: var(--color-text-muted);
|
|
85
|
+
font-weight: 500;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ============================================
|
|
89
|
+
Code Blocks Enhancement
|
|
90
|
+
============================================ */
|
|
91
|
+
pre {
|
|
92
|
+
background-color: var(--color-code-bg);
|
|
93
|
+
border: 1px solid var(--color-border);
|
|
94
|
+
box-shadow: var(--shadow-sm);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
code {
|
|
98
|
+
background-color: var(--color-code-bg);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Dark mode syntax highlighting adjustments */
|
|
102
|
+
@media (prefers-color-scheme: dark) {
|
|
103
|
+
.hljs {
|
|
104
|
+
background: var(--color-code-bg) !important;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ============================================
|
|
109
|
+
Directory Listing Enhancement
|
|
110
|
+
============================================ */
|
|
111
|
+
.directory-listing {
|
|
112
|
+
border: 1px solid var(--color-border);
|
|
113
|
+
border-radius: 6px;
|
|
114
|
+
overflow: hidden;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.directory-listing li {
|
|
118
|
+
background-color: var(--color-bg);
|
|
119
|
+
border-bottom: 1px solid var(--color-border);
|
|
120
|
+
transition: background-color var(--transition-fast);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.directory-listing li:last-child {
|
|
124
|
+
border-bottom: none;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.directory-listing li:hover {
|
|
128
|
+
background-color: var(--color-bg-secondary);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.directory-listing li a {
|
|
132
|
+
display: block;
|
|
133
|
+
padding: 8px 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.directory-listing .size {
|
|
137
|
+
color: var(--color-text-muted);
|
|
138
|
+
font-size: 0.85em;
|
|
139
|
+
margin-left: auto;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Folder icons */
|
|
143
|
+
.directory-listing li.folder::before {
|
|
144
|
+
content: '📁';
|
|
145
|
+
margin-right: 8px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.directory-listing li.parent::before {
|
|
149
|
+
content: '⬆️';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.directory-listing li.file::before {
|
|
153
|
+
content: '📄';
|
|
154
|
+
margin-right: 8px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.directory-listing li.file-md::before {
|
|
158
|
+
content: '📝';
|
|
159
|
+
margin-right: 8px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.directory-listing li.file-code::before {
|
|
163
|
+
content: '💻';
|
|
164
|
+
margin-right: 8px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.directory-listing li.file-image::before {
|
|
168
|
+
content: '🖼️';
|
|
169
|
+
margin-right: 8px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.directory-listing li.file-data::before {
|
|
173
|
+
content: '📊';
|
|
174
|
+
margin-right: 8px;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Keyboard Navigation Focus */
|
|
178
|
+
.directory-listing li.keyboard-focus {
|
|
179
|
+
background-color: var(--color-bg-secondary);
|
|
180
|
+
outline: 2px solid var(--color-link);
|
|
181
|
+
outline-offset: -2px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.directory-listing li.keyboard-focus a {
|
|
185
|
+
color: var(--color-link);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ============================================
|
|
189
|
+
Tables Enhancement
|
|
190
|
+
============================================ */
|
|
191
|
+
table {
|
|
192
|
+
border: 1px solid var(--color-border);
|
|
193
|
+
border-radius: 6px;
|
|
194
|
+
overflow: hidden;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
table th {
|
|
198
|
+
background-color: var(--color-bg-secondary);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
table tr {
|
|
202
|
+
border-top: 1px solid var(--color-border);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
table tr:nth-child(2n) {
|
|
206
|
+
background-color: var(--color-bg-secondary);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ============================================
|
|
210
|
+
Blockquote Enhancement
|
|
211
|
+
============================================ */
|
|
212
|
+
blockquote {
|
|
213
|
+
border-left-color: var(--color-blockquote-border);
|
|
214
|
+
color: var(--color-text-secondary);
|
|
215
|
+
background-color: var(--color-bg-secondary);
|
|
216
|
+
padding: 12px 16px;
|
|
217
|
+
border-radius: 0 6px 6px 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ============================================
|
|
221
|
+
Footer Enhancement
|
|
222
|
+
============================================ */
|
|
223
|
+
footer {
|
|
224
|
+
color: var(--color-text-muted);
|
|
225
|
+
border-top: 1px solid var(--color-border);
|
|
226
|
+
padding-top: 20px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
footer hr {
|
|
230
|
+
display: none;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ============================================
|
|
234
|
+
Mermaid Diagram Enhancement
|
|
235
|
+
============================================ */
|
|
236
|
+
.mermaid {
|
|
237
|
+
background-color: var(--color-bg-secondary);
|
|
238
|
+
padding: 20px;
|
|
239
|
+
border-radius: 6px;
|
|
240
|
+
border: 1px solid var(--color-border);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.mermaid-error {
|
|
244
|
+
background-color: rgba(220, 53, 69, 0.1);
|
|
245
|
+
border-color: var(--color-error);
|
|
246
|
+
color: var(--color-error);
|
|
247
|
+
padding: 16px;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ============================================
|
|
251
|
+
Scrollbar Styling (Webkit)
|
|
252
|
+
============================================ */
|
|
253
|
+
::-webkit-scrollbar {
|
|
254
|
+
width: 8px;
|
|
255
|
+
height: 8px;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
::-webkit-scrollbar-track {
|
|
259
|
+
background: var(--color-bg-secondary);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
::-webkit-scrollbar-thumb {
|
|
263
|
+
background: var(--color-border-strong);
|
|
264
|
+
border-radius: 4px;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
::-webkit-scrollbar-thumb:hover {
|
|
268
|
+
background: var(--color-text-muted);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ============================================
|
|
272
|
+
Focus Styles (Accessibility)
|
|
273
|
+
============================================ */
|
|
274
|
+
a:focus,
|
|
275
|
+
button:focus,
|
|
276
|
+
input:focus {
|
|
277
|
+
outline: 2px solid var(--color-link);
|
|
278
|
+
outline-offset: 2px;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ============================================
|
|
282
|
+
Print Styles
|
|
283
|
+
============================================ */
|
|
284
|
+
@media print {
|
|
285
|
+
:root {
|
|
286
|
+
--color-bg: #ffffff;
|
|
287
|
+
--color-text: #000000;
|
|
288
|
+
}
|
|
289
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createServer } from './server.js';
|
|
4
|
+
import { findReadme } from './utils/readme.js';
|
|
5
|
+
import { findAvailablePort } from './utils/port.js';
|
|
6
|
+
|
|
7
|
+
const program = new Command();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ポート番号を検証
|
|
11
|
+
* @param {string} value - 入力値
|
|
12
|
+
* @returns {number} - 検証済みポート番号
|
|
13
|
+
*/
|
|
14
|
+
function validatePort(value) {
|
|
15
|
+
const port = parseInt(value, 10);
|
|
16
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
17
|
+
throw new Error(`Invalid port number: ${value}. Must be between 1 and 65535.`);
|
|
18
|
+
}
|
|
19
|
+
return port;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* CLI オプションをパースする
|
|
24
|
+
* @param {string[]} argv - コマンドライン引数
|
|
25
|
+
* @returns {object} - パースされたオプション
|
|
26
|
+
*/
|
|
27
|
+
export function parseCLI(argv) {
|
|
28
|
+
// Check if readme subcommand is being used
|
|
29
|
+
const args = argv.slice(2);
|
|
30
|
+
const isReadmeCommand = args[0] === 'readme';
|
|
31
|
+
|
|
32
|
+
if (isReadmeCommand) {
|
|
33
|
+
// Parse readme subcommand with its options
|
|
34
|
+
program
|
|
35
|
+
.name('mdv readme')
|
|
36
|
+
.description('Find and display the nearest README.md')
|
|
37
|
+
.option('-p, --port <number>', 'Server port', validatePort, 3000);
|
|
38
|
+
|
|
39
|
+
program.parse(argv);
|
|
40
|
+
const cmdOptions = program.opts();
|
|
41
|
+
|
|
42
|
+
// Handle readme command
|
|
43
|
+
const readmePath = findReadme(process.cwd());
|
|
44
|
+
|
|
45
|
+
if (!readmePath) {
|
|
46
|
+
console.error('Error: README.md not found');
|
|
47
|
+
console.error('Searched from:', process.cwd());
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const dirPath = path.dirname(readmePath);
|
|
52
|
+
const fileName = path.basename(readmePath);
|
|
53
|
+
const port = cmdOptions.port || 3000;
|
|
54
|
+
|
|
55
|
+
console.log(`Found: ${readmePath}`);
|
|
56
|
+
|
|
57
|
+
const app = createServer({ dir: dirPath });
|
|
58
|
+
|
|
59
|
+
app.listen(port, 'localhost', async () => {
|
|
60
|
+
const url = `http://localhost:${port}/${fileName}`;
|
|
61
|
+
console.log(`Opening ${url}`);
|
|
62
|
+
|
|
63
|
+
// ブラウザを開く
|
|
64
|
+
try {
|
|
65
|
+
const open = await import('open');
|
|
66
|
+
await open.default(url);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.log(`Please open manually: ${url}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Return empty object since we handled the command
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Default: parse as regular server options
|
|
77
|
+
program
|
|
78
|
+
.name('mdv')
|
|
79
|
+
.description('Serve Markdown files as HTML')
|
|
80
|
+
.version('2.0.0')
|
|
81
|
+
.option('-p, --port <number>', 'Server port', validatePort, 3000)
|
|
82
|
+
.option('-H, --host <string>', 'Bind address', 'localhost')
|
|
83
|
+
.option('-d, --dir <path>', 'Document root directory', '.')
|
|
84
|
+
.option('--no-watch', 'Disable file watching')
|
|
85
|
+
.option('-q, --quiet', 'Suppress log output')
|
|
86
|
+
.option('--debug', 'Enable debug logging')
|
|
87
|
+
.allowUnknownOption();
|
|
88
|
+
|
|
89
|
+
program.parse(argv);
|
|
90
|
+
return program.opts();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* CLI を実行し、サーバーを起動する
|
|
95
|
+
* @param {string[]} argv - コマンドライン引数
|
|
96
|
+
*/
|
|
97
|
+
export async function run(argv) {
|
|
98
|
+
try {
|
|
99
|
+
// サブコマンドがある場合は parseCLI 内で処理される
|
|
100
|
+
const args = argv.slice(2);
|
|
101
|
+
if (args[0] === 'readme') {
|
|
102
|
+
parseCLI(argv);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const options = parseCLI(argv);
|
|
107
|
+
const app = createServer(options);
|
|
108
|
+
|
|
109
|
+
// 空きポートを検索
|
|
110
|
+
let actualPort;
|
|
111
|
+
try {
|
|
112
|
+
actualPort = await findAvailablePort(options.port, 10, options.quiet);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(`Error: ${err.message}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 元のポートと異なる場合は通知
|
|
119
|
+
if (actualPort !== options.port && !options.quiet) {
|
|
120
|
+
console.log(`Port ${options.port} is in use.`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
app.listen(actualPort, options.host, () => {
|
|
124
|
+
if (!options.quiet) {
|
|
125
|
+
console.log(`mdv running at http://${options.host}:${actualPort}`);
|
|
126
|
+
console.log(`Document root: ${options.dir}`);
|
|
127
|
+
if (options.debug) {
|
|
128
|
+
console.log('Debug mode enabled');
|
|
129
|
+
console.log('Options:', { ...options, port: actualPort });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error(`Error: ${error.message}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createServer } from './server.js';
|
|
2
|
+
import { parseCLI } from './cli.js';
|
|
3
|
+
|
|
4
|
+
export { createServer, parseCLI };
|
|
5
|
+
|
|
6
|
+
// CLI から直接実行された場合
|
|
7
|
+
if (process.argv[1] && process.argv[1].includes('index.js')) {
|
|
8
|
+
const options = parseCLI(process.argv);
|
|
9
|
+
const app = createServer(options);
|
|
10
|
+
|
|
11
|
+
app.listen(parseInt(options.port, 10), options.host, () => {
|
|
12
|
+
console.log(`mdv running at http://${options.host}:${options.port}`);
|
|
13
|
+
console.log(`Document root: ${options.dir}`);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
// エラーテンプレートを読み込み
|
|
10
|
+
let errorTemplate;
|
|
11
|
+
try {
|
|
12
|
+
const templatePath = path.join(__dirname, '../../templates/error.html');
|
|
13
|
+
errorTemplate = fs.readFileSync(templatePath, 'utf-8');
|
|
14
|
+
} catch {
|
|
15
|
+
// テンプレートが読み込めない場合のフォールバック
|
|
16
|
+
errorTemplate = `
|
|
17
|
+
<!DOCTYPE html>
|
|
18
|
+
<html>
|
|
19
|
+
<head><title>{{title}}</title></head>
|
|
20
|
+
<body>
|
|
21
|
+
<h1>{{statusCode}} - {{title}}</h1>
|
|
22
|
+
<p>{{message}}</p>
|
|
23
|
+
<a href="/">Go to Home</a>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* エラーページをレンダリング
|
|
31
|
+
* @param {object} data - テンプレートデータ
|
|
32
|
+
* @returns {string} - レンダリング済み HTML
|
|
33
|
+
*/
|
|
34
|
+
function renderErrorPage(data) {
|
|
35
|
+
let html = errorTemplate;
|
|
36
|
+
|
|
37
|
+
// {{#if details}} ... {{/if}} の処理
|
|
38
|
+
if (data.details) {
|
|
39
|
+
html = html.replace(/\{\{#if details\}\}([\s\S]*?)\{\{\/if\}\}/g, '$1');
|
|
40
|
+
} else {
|
|
41
|
+
html = html.replace(/\{\{#if details\}\}[\s\S]*?\{\{\/if\}\}/g, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 変数置換
|
|
45
|
+
for (const [key, value] of Object.entries(data)) {
|
|
46
|
+
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
|
|
47
|
+
html = html.replace(regex, value ?? '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return html;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 404 ハンドラ
|
|
55
|
+
*/
|
|
56
|
+
export function notFoundHandler(req, res, next) {
|
|
57
|
+
logger.warn(`404 Not Found: ${req.path}`, { method: req.method, ip: req.ip });
|
|
58
|
+
|
|
59
|
+
res.status(404);
|
|
60
|
+
const html = renderErrorPage({
|
|
61
|
+
statusCode: '404',
|
|
62
|
+
title: 'Page Not Found',
|
|
63
|
+
message: `The requested path "${req.path}" was not found on this server.`,
|
|
64
|
+
details: null
|
|
65
|
+
});
|
|
66
|
+
res.type('html').send(html);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* グローバルエラーハンドラ
|
|
71
|
+
*/
|
|
72
|
+
export function errorHandler(err, req, res, next) {
|
|
73
|
+
// ステータスコードを決定
|
|
74
|
+
const statusCode = err.statusCode || err.status || 500;
|
|
75
|
+
|
|
76
|
+
// エラーをログに記録
|
|
77
|
+
logger.error(`Error ${statusCode}: ${err.message}`, {
|
|
78
|
+
path: req.path,
|
|
79
|
+
method: req.method,
|
|
80
|
+
stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
res.status(statusCode);
|
|
84
|
+
|
|
85
|
+
// 本番環境ではスタックトレースを隠す
|
|
86
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
87
|
+
|
|
88
|
+
const html = renderErrorPage({
|
|
89
|
+
statusCode: String(statusCode),
|
|
90
|
+
title: getErrorTitle(statusCode),
|
|
91
|
+
message: isProduction && statusCode >= 500
|
|
92
|
+
? 'An internal error occurred. Please try again later.'
|
|
93
|
+
: err.message,
|
|
94
|
+
details: !isProduction && err.stack ? err.stack : null
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
res.type('html').send(html);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* ステータスコードからエラータイトルを取得
|
|
102
|
+
*/
|
|
103
|
+
function getErrorTitle(statusCode) {
|
|
104
|
+
const titles = {
|
|
105
|
+
400: 'Bad Request',
|
|
106
|
+
401: 'Unauthorized',
|
|
107
|
+
403: 'Forbidden',
|
|
108
|
+
404: 'Not Found',
|
|
109
|
+
405: 'Method Not Allowed',
|
|
110
|
+
408: 'Request Timeout',
|
|
111
|
+
429: 'Too Many Requests',
|
|
112
|
+
500: 'Internal Server Error',
|
|
113
|
+
502: 'Bad Gateway',
|
|
114
|
+
503: 'Service Unavailable',
|
|
115
|
+
504: 'Gateway Timeout'
|
|
116
|
+
};
|
|
117
|
+
return titles[statusCode] || 'Error';
|
|
118
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* セキュリティヘッダーミドルウェア
|
|
3
|
+
*/
|
|
4
|
+
export function securityHeaders(req, res, next) {
|
|
5
|
+
// Content Security Policy
|
|
6
|
+
res.setHeader('Content-Security-Policy', [
|
|
7
|
+
"default-src 'self'",
|
|
8
|
+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
|
9
|
+
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
|
10
|
+
"font-src 'self' https://cdn.jsdelivr.net data:",
|
|
11
|
+
"img-src 'self' data: https:",
|
|
12
|
+
"connect-src 'self'"
|
|
13
|
+
].join('; '));
|
|
14
|
+
|
|
15
|
+
// その他のセキュリティヘッダー
|
|
16
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
17
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
18
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
19
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
20
|
+
|
|
21
|
+
// キャッシュ制御(開発向けに無効化)
|
|
22
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
23
|
+
res.setHeader('Pragma', 'no-cache');
|
|
24
|
+
|
|
25
|
+
next();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* パストラバーサル検出ミドルウェア
|
|
30
|
+
*/
|
|
31
|
+
export function pathTraversalGuard(req, res, next) {
|
|
32
|
+
const suspiciousPatterns = [
|
|
33
|
+
/\.\.\//, // ../
|
|
34
|
+
/\.\.\\/, // ..\
|
|
35
|
+
/%2e%2e[\/\\]/i, // URL encoded ../
|
|
36
|
+
/%252e%252e/i, // Double URL encoded
|
|
37
|
+
/\0/, // Null byte
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const path = req.path;
|
|
41
|
+
|
|
42
|
+
for (const pattern of suspiciousPatterns) {
|
|
43
|
+
if (pattern.test(path)) {
|
|
44
|
+
return res.status(403).send('Access denied: Suspicious path pattern detected');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
next();
|
|
49
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { validatePath } from '../utils/path.js';
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 再帰的にファイル一覧を取得
|
|
10
|
+
* @param {string} dir - ディレクトリパス
|
|
11
|
+
* @param {string} baseDir - ベースディレクトリ(相対パス計算用)
|
|
12
|
+
* @param {number} maxDepth - 最大深度
|
|
13
|
+
* @param {number} currentDepth - 現在の深度
|
|
14
|
+
* @returns {Promise<string[]>} - ファイルパスの配列
|
|
15
|
+
*/
|
|
16
|
+
async function getAllFiles(dir, baseDir, maxDepth = 5, currentDepth = 0) {
|
|
17
|
+
if (currentDepth >= maxDepth) return [];
|
|
18
|
+
|
|
19
|
+
const files = [];
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
23
|
+
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
// 隠しファイル・ディレクトリをスキップ
|
|
26
|
+
if (entry.name.startsWith('.')) continue;
|
|
27
|
+
|
|
28
|
+
// node_modules をスキップ
|
|
29
|
+
if (entry.name === 'node_modules') continue;
|
|
30
|
+
|
|
31
|
+
const fullPath = path.join(dir, entry.name);
|
|
32
|
+
const relativePath = '/' + path.relative(baseDir, fullPath);
|
|
33
|
+
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
const subFiles = await getAllFiles(fullPath, baseDir, maxDepth, currentDepth + 1);
|
|
36
|
+
files.push(...subFiles);
|
|
37
|
+
} else {
|
|
38
|
+
files.push(relativePath);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// ディレクトリ読み取りエラーは無視
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 検索 API エンドポイント
|
|
50
|
+
* GET /api/search?q=query&dir=/path
|
|
51
|
+
*/
|
|
52
|
+
router.get('/api/search', async (req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
const { q, dir = '/' } = req.query;
|
|
55
|
+
|
|
56
|
+
if (!q || typeof q !== 'string' || q.length < 1) {
|
|
57
|
+
return res.json({ results: [], total: 0 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const docRoot = req.app.get('docRoot');
|
|
61
|
+
|
|
62
|
+
let searchDir;
|
|
63
|
+
try {
|
|
64
|
+
searchDir = validatePath(dir, docRoot);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return res.status(400).json({ error: 'Invalid directory path' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ファイル一覧を取得
|
|
70
|
+
const files = await getAllFiles(searchDir, docRoot);
|
|
71
|
+
|
|
72
|
+
// クエリでフィルタ(大文字小文字を区別しない)
|
|
73
|
+
const query = q.toLowerCase();
|
|
74
|
+
const results = files.filter(f => {
|
|
75
|
+
const fileName = path.basename(f).toLowerCase();
|
|
76
|
+
const filePath = f.toLowerCase();
|
|
77
|
+
return fileName.includes(query) || filePath.includes(query);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 結果を制限(最大50件)
|
|
81
|
+
res.json({
|
|
82
|
+
results: results.slice(0, 50),
|
|
83
|
+
total: results.length,
|
|
84
|
+
query: q
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error('Search error:', error);
|
|
88
|
+
res.status(500).json({ error: 'Search failed' });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export default router;
|