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,110 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { validatePath } from '../utils/path.js';
|
|
5
|
+
import { getIconClass, formatFileSize } from '../utils/icons.js';
|
|
6
|
+
import { renderTemplate } from '../utils/template.js';
|
|
7
|
+
import { generateBreadcrumbs } from '../utils/navigation.js';
|
|
8
|
+
import { escapeHtml } from '../utils/html.js';
|
|
9
|
+
|
|
10
|
+
const router = Router();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ディレクトリ一覧表示ルート
|
|
14
|
+
*/
|
|
15
|
+
router.get(/^\/.*/, async (req, res, next) => {
|
|
16
|
+
try {
|
|
17
|
+
const requestPath = req.path;
|
|
18
|
+
const docRoot = req.app.get('docRoot');
|
|
19
|
+
|
|
20
|
+
let dirPath;
|
|
21
|
+
try {
|
|
22
|
+
dirPath = validatePath(requestPath, docRoot);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error.code === 'ETRAVERSAL') {
|
|
25
|
+
return res.status(403).send('Access denied');
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ディレクトリ存在確認
|
|
31
|
+
let stat;
|
|
32
|
+
try {
|
|
33
|
+
stat = await fs.stat(dirPath);
|
|
34
|
+
} catch {
|
|
35
|
+
return next(); // 存在しない場合は次のハンドラへ
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!stat.isDirectory()) {
|
|
39
|
+
return next(); // ディレクトリでなければ次のハンドラへ
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ディレクトリ内容を取得
|
|
43
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
44
|
+
|
|
45
|
+
// エントリー情報を取得(サイズなど)
|
|
46
|
+
const entriesWithInfo = await Promise.all(
|
|
47
|
+
entries.map(async (entry) => {
|
|
48
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
49
|
+
let size = 0;
|
|
50
|
+
let mtime = new Date();
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const entryStat = await fs.stat(fullPath);
|
|
54
|
+
size = entryStat.size;
|
|
55
|
+
mtime = entryStat.mtime;
|
|
56
|
+
} catch {
|
|
57
|
+
// エラーは無視
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name: entry.name,
|
|
62
|
+
isDirectory: entry.isDirectory(),
|
|
63
|
+
size,
|
|
64
|
+
mtime
|
|
65
|
+
};
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// ソート(ディレクトリ優先、アルファベット順)
|
|
70
|
+
const sorted = entriesWithInfo.sort((a, b) => {
|
|
71
|
+
if (a.isDirectory && !b.isDirectory) return -1;
|
|
72
|
+
if (!a.isDirectory && b.isDirectory) return 1;
|
|
73
|
+
return a.name.localeCompare(b.name);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 隠しファイルをフィルタ(オプション)
|
|
77
|
+
const visible = sorted.filter(entry => !entry.name.startsWith('.'));
|
|
78
|
+
|
|
79
|
+
// HTML リスト生成
|
|
80
|
+
const listHtml = visible.map(entry => {
|
|
81
|
+
const isDir = entry.isDirectory;
|
|
82
|
+
const href = path.join(requestPath, entry.name) + (isDir ? '/' : '');
|
|
83
|
+
const iconClass = getIconClass(entry.name, isDir);
|
|
84
|
+
const sizeText = isDir ? '-' : formatFileSize(entry.size);
|
|
85
|
+
|
|
86
|
+
return `<li class="${iconClass}">
|
|
87
|
+
<a href="${escapeHtml(href)}">${escapeHtml(entry.name)}${isDir ? '/' : ''}</a>
|
|
88
|
+
<span class="size">${sizeText}</span>
|
|
89
|
+
</li>`;
|
|
90
|
+
}).join('\n');
|
|
91
|
+
|
|
92
|
+
// 親ディレクトリへのリンク(ルート以外)
|
|
93
|
+
const parentLink = requestPath !== '/'
|
|
94
|
+
? `<li class="folder parent"><a href="${path.dirname(requestPath)}/">..</a><span class="size">-</span></li>\n`
|
|
95
|
+
: '';
|
|
96
|
+
|
|
97
|
+
const html = renderTemplate('page', {
|
|
98
|
+
title: `Index of ${requestPath}`,
|
|
99
|
+
content: `<h1>Index of ${escapeHtml(requestPath)}</h1>
|
|
100
|
+
<ul class="directory-listing">${parentLink}${listHtml}</ul>`,
|
|
101
|
+
breadcrumbs: generateBreadcrumbs(requestPath)
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
res.type('html').send(html);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
next(error);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export default router;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { validatePath } from '../utils/path.js';
|
|
5
|
+
import { renderTemplate } from '../utils/template.js';
|
|
6
|
+
import { escapeHtml } from '../utils/html.js';
|
|
7
|
+
import { generateBreadcrumbs } from '../utils/navigation.js';
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Markdown ファイルのレンダリングルート
|
|
13
|
+
* .md ファイルへのリクエストを処理
|
|
14
|
+
*/
|
|
15
|
+
router.get(/.*\.md$/i, async (req, res, next) => {
|
|
16
|
+
try {
|
|
17
|
+
const requestPath = req.path;
|
|
18
|
+
const docRoot = req.app.get('docRoot');
|
|
19
|
+
|
|
20
|
+
// パス検証(パストラバーサル防止)
|
|
21
|
+
const filePath = validatePath(requestPath, docRoot);
|
|
22
|
+
|
|
23
|
+
// ファイル存在確認
|
|
24
|
+
try {
|
|
25
|
+
await fs.access(filePath);
|
|
26
|
+
} catch {
|
|
27
|
+
return res.status(404).send('File not found');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ファイル読み込み
|
|
31
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
32
|
+
|
|
33
|
+
// テンプレートにMarkdownを埋め込み(クライアント側でレンダリング)
|
|
34
|
+
const html = renderTemplate('page', {
|
|
35
|
+
title: path.basename(filePath),
|
|
36
|
+
content: `<div id="markdown-source" style="display:none">${escapeHtml(content)}</div>
|
|
37
|
+
<div id="markdown-rendered"></div>`,
|
|
38
|
+
breadcrumbs: generateBreadcrumbs(requestPath)
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
res.type('html').send(html);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT') {
|
|
44
|
+
res.status(404).send('File not found');
|
|
45
|
+
} else if (error.code === 'ETRAVERSAL') {
|
|
46
|
+
res.status(403).send('Access denied');
|
|
47
|
+
} else {
|
|
48
|
+
next(error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export default router;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { validatePath } from '../utils/path.js';
|
|
5
|
+
import { renderTemplate } from '../utils/template.js';
|
|
6
|
+
import { getLanguageFromExtension, isSupportedExtension } from '../utils/language.js';
|
|
7
|
+
import { escapeHtml } from '../utils/html.js';
|
|
8
|
+
import { generateBreadcrumbs } from '../utils/navigation.js';
|
|
9
|
+
|
|
10
|
+
const router = Router();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Raw コード表示ルート
|
|
14
|
+
* 非 Markdown ファイルをシンタックスハイライト付きで表示
|
|
15
|
+
*/
|
|
16
|
+
router.get(/^\/.*/, async (req, res, next) => {
|
|
17
|
+
try {
|
|
18
|
+
const requestPath = req.path;
|
|
19
|
+
const ext = path.extname(requestPath).toLowerCase();
|
|
20
|
+
|
|
21
|
+
// Markdown ファイルは除外(markdown router が処理)
|
|
22
|
+
if (ext === '.md') {
|
|
23
|
+
return next();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// サポートされていない拡張子は次のハンドラへ
|
|
27
|
+
if (!isSupportedExtension(ext)) {
|
|
28
|
+
return next();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const docRoot = req.app.get('docRoot');
|
|
32
|
+
let filePath;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
filePath = validatePath(requestPath, docRoot);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === 'ETRAVERSAL') {
|
|
38
|
+
return res.status(403).send('Access denied');
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ファイル存在確認
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
stat = await fs.stat(filePath);
|
|
47
|
+
} catch {
|
|
48
|
+
return next(); // 存在しない場合は次のハンドラへ
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ディレクトリの場合は次のハンドラへ
|
|
52
|
+
if (stat.isDirectory()) {
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ファイル読み込み
|
|
57
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
58
|
+
const language = getLanguageFromExtension(ext);
|
|
59
|
+
const fileName = path.basename(filePath);
|
|
60
|
+
|
|
61
|
+
const html = renderTemplate('page', {
|
|
62
|
+
title: fileName,
|
|
63
|
+
content: `<h1>${escapeHtml(fileName)}</h1>
|
|
64
|
+
<pre><code class="language-${language}">${escapeHtml(content)}</code></pre>`,
|
|
65
|
+
breadcrumbs: generateBreadcrumbs(requestPath)
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
res.type('html').send(html);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
next(error);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export default router;
|
package/src/server.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import markdownRouter from './routes/markdown.js';
|
|
5
|
+
import rawRouter from './routes/raw.js';
|
|
6
|
+
import directoryRouter from './routes/directory.js';
|
|
7
|
+
import apiRouter from './routes/api.js';
|
|
8
|
+
import { securityHeaders, pathTraversalGuard } from './middleware/security.js';
|
|
9
|
+
import { notFoundHandler, errorHandler } from './middleware/error.js';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Express サーバーを作成
|
|
16
|
+
* @param {object} options - サーバーオプション
|
|
17
|
+
* @param {string} options.dir - ドキュメントルート
|
|
18
|
+
* @returns {express.Application} - Express アプリケーション
|
|
19
|
+
*/
|
|
20
|
+
export function createServer(options = {}) {
|
|
21
|
+
const app = express();
|
|
22
|
+
|
|
23
|
+
// ドキュメントルートの設定
|
|
24
|
+
const docRoot = path.resolve(options.dir || '.');
|
|
25
|
+
app.set('docRoot', docRoot);
|
|
26
|
+
|
|
27
|
+
// セキュリティミドルウェア(最初に登録)
|
|
28
|
+
app.use(securityHeaders);
|
|
29
|
+
app.use(pathTraversalGuard);
|
|
30
|
+
|
|
31
|
+
// 静的ファイル配信(public ディレクトリ)
|
|
32
|
+
const publicDir = path.join(__dirname, '..', 'public');
|
|
33
|
+
app.use('/static', express.static(publicDir));
|
|
34
|
+
|
|
35
|
+
// ヘルスチェック
|
|
36
|
+
app.get('/health', (req, res) => {
|
|
37
|
+
res.json({
|
|
38
|
+
status: 'ok',
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
docRoot: docRoot
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// API ルート
|
|
45
|
+
app.use(apiRouter);
|
|
46
|
+
|
|
47
|
+
// Markdown ルート(.md ファイル)
|
|
48
|
+
app.use(markdownRouter);
|
|
49
|
+
|
|
50
|
+
// Raw コード表示ルート(.js, .py 等)
|
|
51
|
+
app.use(rawRouter);
|
|
52
|
+
|
|
53
|
+
// ディレクトリ一覧ルート
|
|
54
|
+
app.use(directoryRouter);
|
|
55
|
+
|
|
56
|
+
// エラーハンドラ(最後に登録)
|
|
57
|
+
app.use(notFoundHandler);
|
|
58
|
+
app.use(errorHandler);
|
|
59
|
+
|
|
60
|
+
return app;
|
|
61
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML 特殊文字をエスケープ
|
|
3
|
+
* @param {string} str - エスケープ対象文字列
|
|
4
|
+
* @returns {string} - エスケープ済み文字列
|
|
5
|
+
*/
|
|
6
|
+
export function escapeHtml(str) {
|
|
7
|
+
if (typeof str !== 'string') {
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
const map = {
|
|
11
|
+
'&': '&',
|
|
12
|
+
'<': '<',
|
|
13
|
+
'>': '>',
|
|
14
|
+
'"': '"',
|
|
15
|
+
"'": '''
|
|
16
|
+
};
|
|
17
|
+
return str.replace(/[&<>"']/g, c => map[c]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* HTML エスケープを解除
|
|
22
|
+
* @param {string} str - エスケープ解除対象文字列
|
|
23
|
+
* @returns {string} - エスケープ解除済み文字列
|
|
24
|
+
*/
|
|
25
|
+
export function unescapeHtml(str) {
|
|
26
|
+
if (typeof str !== 'string') {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
const map = {
|
|
30
|
+
'&': '&',
|
|
31
|
+
'<': '<',
|
|
32
|
+
'>': '>',
|
|
33
|
+
'"': '"',
|
|
34
|
+
''': "'"
|
|
35
|
+
};
|
|
36
|
+
return str.replace(/&|<|>|"|'/g, m => map[m]);
|
|
37
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ファイル名から適切なアイコンクラスを取得
|
|
3
|
+
* @param {string} fileName - ファイル名
|
|
4
|
+
* @param {boolean} isDirectory - ディレクトリかどうか
|
|
5
|
+
* @returns {string} - CSS クラス名
|
|
6
|
+
*/
|
|
7
|
+
export function getIconClass(fileName, isDirectory) {
|
|
8
|
+
if (isDirectory) {
|
|
9
|
+
return 'folder';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
13
|
+
|
|
14
|
+
// 拡張子に基づくアイコンクラス
|
|
15
|
+
const iconMap = {
|
|
16
|
+
// Markdown
|
|
17
|
+
'md': 'file-md',
|
|
18
|
+
'markdown': 'file-md',
|
|
19
|
+
|
|
20
|
+
// Code
|
|
21
|
+
'js': 'file-code',
|
|
22
|
+
'ts': 'file-code',
|
|
23
|
+
'jsx': 'file-code',
|
|
24
|
+
'tsx': 'file-code',
|
|
25
|
+
'py': 'file-code',
|
|
26
|
+
'rb': 'file-code',
|
|
27
|
+
'java': 'file-code',
|
|
28
|
+
'c': 'file-code',
|
|
29
|
+
'cpp': 'file-code',
|
|
30
|
+
'h': 'file-code',
|
|
31
|
+
'go': 'file-code',
|
|
32
|
+
'rs': 'file-code',
|
|
33
|
+
|
|
34
|
+
// Web
|
|
35
|
+
'html': 'file-web',
|
|
36
|
+
'htm': 'file-web',
|
|
37
|
+
'css': 'file-web',
|
|
38
|
+
'scss': 'file-web',
|
|
39
|
+
'less': 'file-web',
|
|
40
|
+
|
|
41
|
+
// Data
|
|
42
|
+
'json': 'file-data',
|
|
43
|
+
'xml': 'file-data',
|
|
44
|
+
'yaml': 'file-data',
|
|
45
|
+
'yml': 'file-data',
|
|
46
|
+
'toml': 'file-data',
|
|
47
|
+
|
|
48
|
+
// Images
|
|
49
|
+
'png': 'file-image',
|
|
50
|
+
'jpg': 'file-image',
|
|
51
|
+
'jpeg': 'file-image',
|
|
52
|
+
'gif': 'file-image',
|
|
53
|
+
'svg': 'file-image',
|
|
54
|
+
'webp': 'file-image',
|
|
55
|
+
|
|
56
|
+
// Documents
|
|
57
|
+
'pdf': 'file-doc',
|
|
58
|
+
'doc': 'file-doc',
|
|
59
|
+
'docx': 'file-doc',
|
|
60
|
+
'txt': 'file-text'
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return iconMap[ext] || 'file';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ファイルサイズを人間が読みやすい形式に変換
|
|
68
|
+
* @param {number} bytes - バイト数
|
|
69
|
+
* @returns {string} - フォーマット済みサイズ
|
|
70
|
+
*/
|
|
71
|
+
export function formatFileSize(bytes) {
|
|
72
|
+
if (bytes === 0) return '0 B';
|
|
73
|
+
|
|
74
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
75
|
+
const k = 1024;
|
|
76
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
77
|
+
|
|
78
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + units[i];
|
|
79
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ファイル拡張子から highlight.js の言語名を取得
|
|
3
|
+
* @param {string} ext - ファイル拡張子(.付き)
|
|
4
|
+
* @returns {string} - highlight.js の言語名
|
|
5
|
+
*/
|
|
6
|
+
export function getLanguageFromExtension(ext) {
|
|
7
|
+
const languageMap = {
|
|
8
|
+
// JavaScript
|
|
9
|
+
'.js': 'javascript',
|
|
10
|
+
'.mjs': 'javascript',
|
|
11
|
+
'.cjs': 'javascript',
|
|
12
|
+
'.jsx': 'javascript',
|
|
13
|
+
|
|
14
|
+
// TypeScript
|
|
15
|
+
'.ts': 'typescript',
|
|
16
|
+
'.tsx': 'typescript',
|
|
17
|
+
|
|
18
|
+
// Web
|
|
19
|
+
'.html': 'html',
|
|
20
|
+
'.htm': 'html',
|
|
21
|
+
'.css': 'css',
|
|
22
|
+
'.scss': 'scss',
|
|
23
|
+
'.less': 'less',
|
|
24
|
+
|
|
25
|
+
// Data formats
|
|
26
|
+
'.json': 'json',
|
|
27
|
+
'.xml': 'xml',
|
|
28
|
+
'.yaml': 'yaml',
|
|
29
|
+
'.yml': 'yaml',
|
|
30
|
+
'.toml': 'toml',
|
|
31
|
+
|
|
32
|
+
// Python
|
|
33
|
+
'.py': 'python',
|
|
34
|
+
'.pyw': 'python',
|
|
35
|
+
|
|
36
|
+
// Ruby
|
|
37
|
+
'.rb': 'ruby',
|
|
38
|
+
'.erb': 'erb',
|
|
39
|
+
|
|
40
|
+
// Go
|
|
41
|
+
'.go': 'go',
|
|
42
|
+
|
|
43
|
+
// Rust
|
|
44
|
+
'.rs': 'rust',
|
|
45
|
+
|
|
46
|
+
// Java
|
|
47
|
+
'.java': 'java',
|
|
48
|
+
|
|
49
|
+
// C/C++
|
|
50
|
+
'.c': 'c',
|
|
51
|
+
'.h': 'c',
|
|
52
|
+
'.cpp': 'cpp',
|
|
53
|
+
'.hpp': 'cpp',
|
|
54
|
+
'.cc': 'cpp',
|
|
55
|
+
'.cxx': 'cpp',
|
|
56
|
+
|
|
57
|
+
// Shell
|
|
58
|
+
'.sh': 'bash',
|
|
59
|
+
'.bash': 'bash',
|
|
60
|
+
'.zsh': 'zsh',
|
|
61
|
+
'.fish': 'shell',
|
|
62
|
+
|
|
63
|
+
// SQL
|
|
64
|
+
'.sql': 'sql',
|
|
65
|
+
|
|
66
|
+
// PHP
|
|
67
|
+
'.php': 'php',
|
|
68
|
+
|
|
69
|
+
// Markdown (fallback for non-.md markdown)
|
|
70
|
+
'.markdown': 'markdown',
|
|
71
|
+
|
|
72
|
+
// Plain text
|
|
73
|
+
'.txt': 'plaintext',
|
|
74
|
+
'.text': 'plaintext',
|
|
75
|
+
'.log': 'plaintext'
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return languageMap[ext.toLowerCase()] || 'plaintext';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* ファイル拡張子がサポートされているかチェック
|
|
83
|
+
* @param {string} ext - ファイル拡張子(.付き)
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
export function isSupportedExtension(ext) {
|
|
87
|
+
const supportedExtensions = [
|
|
88
|
+
'.js', '.mjs', '.cjs', '.jsx',
|
|
89
|
+
'.ts', '.tsx',
|
|
90
|
+
'.html', '.htm', '.css', '.scss', '.less',
|
|
91
|
+
'.json', '.xml', '.yaml', '.yml', '.toml',
|
|
92
|
+
'.py', '.pyw',
|
|
93
|
+
'.rb', '.erb',
|
|
94
|
+
'.go', '.rs', '.java',
|
|
95
|
+
'.c', '.h', '.cpp', '.hpp', '.cc', '.cxx',
|
|
96
|
+
'.sh', '.bash', '.zsh', '.fish',
|
|
97
|
+
'.sql', '.php',
|
|
98
|
+
'.txt', '.text', '.log'
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
return supportedExtensions.includes(ext.toLowerCase());
|
|
102
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ログレベル
|
|
3
|
+
*/
|
|
4
|
+
const LOG_LEVELS = {
|
|
5
|
+
DEBUG: 0,
|
|
6
|
+
INFO: 1,
|
|
7
|
+
WARN: 2,
|
|
8
|
+
ERROR: 3,
|
|
9
|
+
SILENT: 4
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 統一ロガークラス
|
|
14
|
+
*/
|
|
15
|
+
class Logger {
|
|
16
|
+
constructor(level = 'INFO') {
|
|
17
|
+
this.level = LOG_LEVELS[level.toUpperCase()] ?? LOG_LEVELS.INFO;
|
|
18
|
+
this.prefix = '[mdv]';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* ログレベルを設定
|
|
23
|
+
* @param {string} level - ログレベル名
|
|
24
|
+
*/
|
|
25
|
+
setLevel(level) {
|
|
26
|
+
this.level = LOG_LEVELS[level.toUpperCase()] ?? LOG_LEVELS.INFO;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* タイムスタンプを生成
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
_timestamp() {
|
|
34
|
+
return new Date().toISOString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ログ出力の共通処理
|
|
39
|
+
* @param {string} levelName - レベル名
|
|
40
|
+
* @param {string} message - メッセージ
|
|
41
|
+
* @param {object} meta - メタデータ
|
|
42
|
+
* @param {function} outputFn - 出力関数
|
|
43
|
+
*/
|
|
44
|
+
_log(levelName, message, meta, outputFn) {
|
|
45
|
+
const timestamp = this._timestamp();
|
|
46
|
+
const metaStr = meta && Object.keys(meta).length > 0
|
|
47
|
+
? ' ' + JSON.stringify(meta)
|
|
48
|
+
: '';
|
|
49
|
+
outputFn(`${timestamp} ${this.prefix} [${levelName}] ${message}${metaStr}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
debug(message, meta = {}) {
|
|
53
|
+
if (this.level <= LOG_LEVELS.DEBUG) {
|
|
54
|
+
this._log('DEBUG', message, meta, console.log);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
info(message, meta = {}) {
|
|
59
|
+
if (this.level <= LOG_LEVELS.INFO) {
|
|
60
|
+
this._log('INFO', message, meta, console.log);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
warn(message, meta = {}) {
|
|
65
|
+
if (this.level <= LOG_LEVELS.WARN) {
|
|
66
|
+
this._log('WARN', message, meta, console.warn);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
error(message, meta = {}) {
|
|
71
|
+
if (this.level <= LOG_LEVELS.ERROR) {
|
|
72
|
+
this._log('ERROR', message, meta, console.error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// シングルトンインスタンスをエクスポート
|
|
78
|
+
export const logger = new Logger(process.env.LOG_LEVEL || 'INFO');
|
|
79
|
+
|
|
80
|
+
// クラスもエクスポート(テスト用)
|
|
81
|
+
export { Logger, LOG_LEVELS };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* パスからブレッドクラム HTML を生成
|
|
3
|
+
* @param {string} requestPath - リクエストパス
|
|
4
|
+
* @returns {string} - ブレッドクラム HTML
|
|
5
|
+
*/
|
|
6
|
+
export function generateBreadcrumbs(requestPath) {
|
|
7
|
+
const parts = requestPath.split('/').filter(Boolean);
|
|
8
|
+
const breadcrumbs = [{ href: '/', text: 'Home' }];
|
|
9
|
+
|
|
10
|
+
let currentPath = '';
|
|
11
|
+
for (const part of parts) {
|
|
12
|
+
currentPath += `/${part}`;
|
|
13
|
+
breadcrumbs.push({ href: currentPath, text: decodeURIComponent(part) });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return breadcrumbs
|
|
17
|
+
.map((crumb, index) => {
|
|
18
|
+
const isLast = index === breadcrumbs.length - 1;
|
|
19
|
+
if (isLast) {
|
|
20
|
+
return `<span class="current">${escapeHtml(crumb.text)}</span>`;
|
|
21
|
+
}
|
|
22
|
+
return `<a href="${crumb.href}">${escapeHtml(crumb.text)}</a>`;
|
|
23
|
+
})
|
|
24
|
+
.join(' / ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* HTML 特殊文字をエスケープ
|
|
29
|
+
* @param {string} str - エスケープ対象文字列
|
|
30
|
+
* @returns {string} - エスケープ済み文字列
|
|
31
|
+
*/
|
|
32
|
+
function escapeHtml(str) {
|
|
33
|
+
const map = {
|
|
34
|
+
'&': '&',
|
|
35
|
+
'<': '<',
|
|
36
|
+
'>': '>',
|
|
37
|
+
'"': '"',
|
|
38
|
+
"'": '''
|
|
39
|
+
};
|
|
40
|
+
return str.replace(/[&<>"']/g, c => map[c]);
|
|
41
|
+
}
|