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.
@@ -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;