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,86 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+
4
+ /**
5
+ * パストラバーサル攻撃を防止するパス検証
6
+ * @param {string} requestPath - リクエストパス
7
+ * @param {string} rootDir - ドキュメントルート
8
+ * @returns {string} - 検証済み絶対パス
9
+ * @throws {Error} - パストラバーサル検出時またはファイル不在時
10
+ */
11
+ export function validatePath(requestPath, rootDir) {
12
+ // 1. URL デコード
13
+ const decodedPath = decodeURIComponent(requestPath);
14
+
15
+ // 2. null バイトのチェック
16
+ if (decodedPath.includes('\0')) {
17
+ const error = new Error('Null byte detected in path');
18
+ error.code = 'ETRAVERSAL';
19
+ throw error;
20
+ }
21
+
22
+ // 3. パス正規化
23
+ const normalizedPath = path.normalize(decodedPath);
24
+
25
+ // 4. 絶対パスに変換
26
+ const absolutePath = path.resolve(rootDir, '.' + normalizedPath);
27
+
28
+ // 5. realpath でドキュメントルートを解決
29
+ let realRootDir;
30
+ try {
31
+ realRootDir = fs.realpathSync(rootDir);
32
+ } catch (e) {
33
+ realRootDir = path.resolve(rootDir);
34
+ }
35
+
36
+ // 6. ファイル/ディレクトリが存在する場合は realpath でシンボリックリンク解決
37
+ let realPath;
38
+ try {
39
+ realPath = fs.realpathSync(absolutePath);
40
+ } catch (e) {
41
+ if (e.code === 'ENOENT') {
42
+ // ファイルが存在しない場合は親ディレクトリで検証
43
+ const parentDir = path.dirname(absolutePath);
44
+ let realParent;
45
+ try {
46
+ realParent = fs.realpathSync(parentDir);
47
+ } catch {
48
+ realParent = parentDir;
49
+ }
50
+
51
+ if (!realParent.startsWith(realRootDir)) {
52
+ const error = new Error('Path traversal detected');
53
+ error.code = 'ETRAVERSAL';
54
+ throw error;
55
+ }
56
+
57
+ // 元のエラーを再スロー(ファイルが存在しない)
58
+ throw e;
59
+ }
60
+ throw e;
61
+ }
62
+
63
+ // 7. ドキュメントルート以下か確認
64
+ if (!realPath.startsWith(realRootDir)) {
65
+ const error = new Error('Path traversal detected');
66
+ error.code = 'ETRAVERSAL';
67
+ throw error;
68
+ }
69
+
70
+ return realPath;
71
+ }
72
+
73
+ /**
74
+ * パスが安全かどうかをチェック(例外を投げない版)
75
+ * @param {string} requestPath - リクエストパス
76
+ * @param {string} rootDir - ドキュメントルート
77
+ * @returns {{ valid: boolean, path?: string, error?: string }}
78
+ */
79
+ export function isPathSafe(requestPath, rootDir) {
80
+ try {
81
+ const safePath = validatePath(requestPath, rootDir);
82
+ return { valid: true, path: safePath };
83
+ } catch (error) {
84
+ return { valid: false, error: error.message };
85
+ }
86
+ }
@@ -0,0 +1,49 @@
1
+ import net from 'net';
2
+
3
+ /**
4
+ * 指定ポートが利用可能かチェック
5
+ * @param {number} port - チェックするポート
6
+ * @returns {Promise<boolean>} - 利用可能なら true
7
+ */
8
+ export function checkPortAvailable(port) {
9
+ return new Promise((resolve) => {
10
+ const server = net.createServer();
11
+
12
+ server.once('error', (err) => {
13
+ if (err.code === 'EADDRINUSE') {
14
+ resolve(false);
15
+ } else {
16
+ resolve(false);
17
+ }
18
+ });
19
+
20
+ server.once('listening', () => {
21
+ server.close();
22
+ resolve(true);
23
+ });
24
+
25
+ server.listen(port, '127.0.0.1');
26
+ });
27
+ }
28
+
29
+ /**
30
+ * 空きポートを検索
31
+ * @param {number} preferredPort - 優先ポート
32
+ * @param {number} maxAttempts - 最大試行回数(デフォルト: 10)
33
+ * @param {boolean} quiet - ログ出力を抑制するか(デフォルト: false)
34
+ * @returns {Promise<number>} - 利用可能なポート
35
+ * @throws {Error} - 空きポートが見つからない場合
36
+ */
37
+ export async function findAvailablePort(preferredPort = 3000, maxAttempts = 10, quiet = false) {
38
+ for (let i = 0; i < maxAttempts; i++) {
39
+ const port = preferredPort + i;
40
+ const isAvailable = await checkPortAvailable(port);
41
+ if (isAvailable) {
42
+ return port;
43
+ }
44
+ if (!quiet) {
45
+ console.log(`Port ${port} is in use, trying ${port + 1}...`);
46
+ }
47
+ }
48
+ throw new Error(`No available port found in range ${preferredPort}-${preferredPort + maxAttempts - 1}`);
49
+ }
@@ -0,0 +1,36 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * 上位ディレクトリを遡って README.md を検索
6
+ * @param {string} startDir - 検索開始ディレクトリ
7
+ * @returns {string | null} - README.md の絶対パス、見つからない場合は null
8
+ */
9
+ export function findReadme(startDir) {
10
+ let currentDir = path.resolve(startDir);
11
+ const root = path.parse(currentDir).root;
12
+
13
+ while (currentDir !== root) {
14
+ // 大文字の README.md を優先
15
+ const readmePath = path.join(currentDir, 'README.md');
16
+ if (fs.existsSync(readmePath)) {
17
+ return readmePath;
18
+ }
19
+
20
+ // 小文字の readme.md もチェック
21
+ const readmePathLower = path.join(currentDir, 'readme.md');
22
+ if (fs.existsSync(readmePathLower)) {
23
+ return readmePathLower;
24
+ }
25
+
26
+ // Readme.md(キャメルケース)もチェック
27
+ const readmePathCamel = path.join(currentDir, 'Readme.md');
28
+ if (fs.existsSync(readmePathCamel)) {
29
+ return readmePathCamel;
30
+ }
31
+
32
+ currentDir = path.dirname(currentDir);
33
+ }
34
+
35
+ return null;
36
+ }
@@ -0,0 +1,35 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+
8
+ // テンプレートキャッシュ
9
+ const templateCache = new Map();
10
+
11
+ /**
12
+ * テンプレートをレンダリング
13
+ * @param {string} templateName - テンプレート名(拡張子なし)
14
+ * @param {object} variables - テンプレート変数
15
+ * @returns {string} - レンダリング済み HTML
16
+ */
17
+ export function renderTemplate(templateName, variables = {}) {
18
+ const templatePath = path.join(__dirname, '../../templates', `${templateName}.html`);
19
+
20
+ // キャッシュからテンプレートを取得、なければ読み込み
21
+ let template = templateCache.get(templatePath);
22
+ if (!template) {
23
+ template = fs.readFileSync(templatePath, 'utf-8');
24
+ templateCache.set(templatePath, template);
25
+ }
26
+
27
+ // 変数を置換
28
+ let html = template;
29
+ for (const [key, value] of Object.entries(variables)) {
30
+ const regex = new RegExp(`{{${key}}}`, 'g');
31
+ html = html.replace(regex, value ?? '');
32
+ }
33
+
34
+ return html;
35
+ }
File without changes
@@ -0,0 +1,76 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{title}}</title>
7
+ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
8
+ <link rel="stylesheet" href="/static/styles/base.css">
9
+ <link rel="stylesheet" href="/static/styles/modern.css">
10
+ <style>
11
+ .error-container {
12
+ text-align: center;
13
+ padding: 60px 20px;
14
+ }
15
+ .error-code {
16
+ font-size: 6em;
17
+ font-weight: bold;
18
+ color: var(--color-text-muted, #6a737d);
19
+ margin: 0;
20
+ line-height: 1;
21
+ }
22
+ .error-title {
23
+ font-size: 1.5em;
24
+ margin: 20px 0;
25
+ color: var(--color-text, #24292e);
26
+ }
27
+ .error-message {
28
+ color: var(--color-text-secondary, #586069);
29
+ margin-bottom: 30px;
30
+ }
31
+ .error-actions {
32
+ margin-top: 30px;
33
+ }
34
+ .error-actions a {
35
+ display: inline-block;
36
+ padding: 10px 20px;
37
+ background-color: var(--color-link, #0366d6);
38
+ color: white;
39
+ text-decoration: none;
40
+ border-radius: 6px;
41
+ margin: 0 10px;
42
+ transition: opacity 0.2s;
43
+ }
44
+ .error-actions a:hover {
45
+ opacity: 0.8;
46
+ text-decoration: none;
47
+ }
48
+ .error-details {
49
+ margin-top: 40px;
50
+ padding: 20px;
51
+ background-color: var(--color-bg-secondary, #f6f8fa);
52
+ border-radius: 6px;
53
+ text-align: left;
54
+ font-family: monospace;
55
+ font-size: 12px;
56
+ white-space: pre-wrap;
57
+ word-break: break-all;
58
+ color: var(--color-text-muted, #6a737d);
59
+ }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <main class="error-container">
64
+ <p class="error-code">{{statusCode}}</p>
65
+ <h1 class="error-title">{{title}}</h1>
66
+ <p class="error-message">{{message}}</p>
67
+ <div class="error-actions">
68
+ <a href="/">Go to Home</a>
69
+ <a href="javascript:history.back()">Go Back</a>
70
+ </div>
71
+ {{#if details}}
72
+ <pre class="error-details">{{details}}</pre>
73
+ {{/if}}
74
+ </main>
75
+ </body>
76
+ </html>
@@ -0,0 +1,60 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{title}}</title>
7
+
8
+ <!-- Favicon -->
9
+ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
10
+
11
+ <!-- Stylesheets -->
12
+ <link rel="stylesheet" href="/static/styles/base.css">
13
+ <link rel="stylesheet" href="/static/styles/modern.css">
14
+ <link rel="stylesheet"
15
+ href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github.min.css">
16
+
17
+ <!-- MathJax 設定 -->
18
+ <script>
19
+ MathJax = {
20
+ tex: {
21
+ inlineMath: [['$', '$'], ['\\(', '\\)']],
22
+ displayMath: [['$$', '$$'], ['\\[', '\\]']],
23
+ processEscapes: true
24
+ },
25
+ svg: { fontCache: 'global' },
26
+ startup: {
27
+ ready: () => {
28
+ MathJax.startup.defaultReady();
29
+ window.renderMathJax = () => {
30
+ MathJax.typesetPromise();
31
+ };
32
+ }
33
+ }
34
+ };
35
+ </script>
36
+ <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js" async></script>
37
+ </head>
38
+ <body>
39
+ <nav id="breadcrumbs">{{breadcrumbs}}</nav>
40
+ <main id="content">
41
+ {{content}}
42
+ </main>
43
+ <footer>
44
+ <hr>
45
+ Served by <a href="https://www.npmjs.com/package/markdown-viewer">markdown-viewer</a>
46
+ </footer>
47
+
48
+ <!-- クライアント側ライブラリ -->
49
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
50
+ <script src="https://cdn.jsdelivr.net/npm/highlight.js@11"></script>
51
+ <script type="module">
52
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
53
+ window.mermaid = mermaid;
54
+ mermaid.initialize({ startOnLoad: false });
55
+ </script>
56
+ <script src="/static/js/app.js"></script>
57
+ <script src="/static/js/navigation.js"></script>
58
+ <script src="/static/js/search.js"></script>
59
+ </body>
60
+ </html>