md-lv 1.2.4 → 1.3.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 CHANGED
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-02-16
9
+
10
+ ### Added
11
+
12
+ - **Code Viewer (VSCode-like)**: Non-Markdown files now display with line numbers, indent guides, filename header, and language label in a VSCode-inspired code viewer
13
+ - **Dark Mode Syntax Highlighting**: Automatic highlight.js theme switching (github / github-dark) based on system color scheme preference
14
+ - **Binary File Detection**: Binary files (images, archives, executables, etc.) are now properly skipped instead of attempting to render
15
+ - **Language Display Names**: Code viewer header shows human-readable language names (e.g., "JavaScript" instead of "javascript")
16
+ - **Expanded Language Support**: Added Dockerfile syntax highlighting, extended filename-based language detection (.env, .prettierrc, .eslintrc, ignore files, etc.)
17
+ - **File Size Limit**: Files larger than 1MB are skipped to prevent browser performance issues
18
+
19
+ ### Changed
20
+
21
+ - **Copy Button**: Breadcrumb copy button now copies the relative file path instead of just the filename
22
+ - **highlight.js CDN**: Switched from `highlight.js` to `@highlightjs/cdn-assets` package for better modularity
23
+ - **Language Detection Priority**: Filename-based detection now takes priority over extension-based detection for more accurate results
24
+
25
+ ## [1.2.6] - 2026-02-05
26
+
27
+ ### Fixed
28
+
29
+ - **YAML Front Matter**: Fixed support for YAML multiline string notation (`>-`, `>`, `|`, `|-`)
30
+ - Folded scalars (newlines replaced with spaces)
31
+ - Literal scalars (newlines preserved)
32
+
33
+ ## [1.2.5] - 2026-02-05
34
+
35
+ ### Added
36
+
37
+ - **Sidebar Scroll Position**: Sidebar now preserves scroll position when navigating between files
38
+ - **Sticky Breadcrumbs**: Path display (breadcrumbs) now stays fixed at the top when scrolling
39
+
8
40
  ## [1.2.3] - 2026-01-31
9
41
 
10
42
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-lv",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "description": "Serve Markdown files as HTML with live features - syntax highlighting, Mermaid diagrams, and MathJax formulas",
5
5
  "type": "module",
6
6
  "engines": {
package/public/js/app.js CHANGED
@@ -9,6 +9,9 @@ document.addEventListener('DOMContentLoaded', async () => {
9
9
  // シンタックスハイライト
10
10
  highlightCode();
11
11
 
12
+ // コードビューア拡張(行番号・インデントガイド)
13
+ enhanceCodeViewer();
14
+
12
15
  // Mermaid 図のレンダリング
13
16
  await renderMermaid();
14
17
 
@@ -38,15 +41,56 @@ function parseFrontmatter(markdown) {
38
41
  return { frontmatter: null, body };
39
42
  }
40
43
 
41
- // YAML パース(key: value 形式と配列形式に対応)
44
+ // YAML パース(key: value 形式、配列形式、複数行文字列に対応)
42
45
  const frontmatter = {};
43
46
  const lines = yamlContent.split('\n');
44
47
  let currentKey = null;
48
+ let multilineMode = null; // 'folded' (>) or 'literal' (|)
49
+ let multilineBuffer = [];
50
+ let multilineStripTrailing = false;
51
+
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const line = lines[i];
54
+
55
+ // 複数行モード中の処理
56
+ if (multilineMode && currentKey) {
57
+ // インデントされた行は複数行コンテンツの一部
58
+ if (line.match(/^[ \t]+\S/) || line.trim() === '') {
59
+ // インデントを除去して保存
60
+ const indentMatch = line.match(/^([ \t]+)(.*)$/);
61
+ if (indentMatch) {
62
+ multilineBuffer.push(indentMatch[2]);
63
+ } else if (line.trim() === '') {
64
+ multilineBuffer.push('');
65
+ }
66
+ continue;
67
+ } else {
68
+ // インデントされていない行は複数行モードの終了
69
+ let result = multilineBuffer.join('\n');
70
+
71
+ if (multilineMode === 'folded') {
72
+ // 改行を空白に置き換え(空行は段落区切りとして保持)
73
+ result = result.replace(/\n(?!\n)/g, ' ').replace(/\n\n+/g, '\n\n');
74
+ }
75
+
76
+ // 末尾の改行を処理
77
+ if (multilineStripTrailing) {
78
+ result = result.replace(/\n+$/, '');
79
+ }
80
+
81
+ frontmatter[currentKey] = result.trim();
82
+ multilineMode = null;
83
+ multilineBuffer = [];
84
+ multilineStripTrailing = false;
85
+ // 現在の行を再処理するためにインデックスを戻す
86
+ i--;
87
+ continue;
88
+ }
89
+ }
45
90
 
46
- for (const line of lines) {
47
91
  // 配列アイテム(- value)のチェック
48
92
  const arrayItemMatch = line.match(/^[ \t]+-[ \t]+(.*)$/);
49
- if (arrayItemMatch && currentKey) {
93
+ if (arrayItemMatch && currentKey && !multilineMode) {
50
94
  const itemValue = parseYamlValue(arrayItemMatch[1]);
51
95
  if (!Array.isArray(frontmatter[currentKey])) {
52
96
  frontmatter[currentKey] = [];
@@ -63,15 +107,37 @@ function parseFrontmatter(markdown) {
63
107
 
64
108
  if (key) {
65
109
  currentKey = key;
66
- if (value) {
110
+
111
+ // 複数行文字列の開始記号をチェック
112
+ if (value === '>' || value === '>-' || value === '|' || value === '|-') {
113
+ multilineMode = (value.startsWith('>') ? 'folded' : 'literal');
114
+ multilineStripTrailing = value.endsWith('-');
115
+ multilineBuffer = [];
116
+ } else if (value) {
67
117
  // 同じ行に値がある場合
68
118
  frontmatter[key] = parseYamlValue(value);
119
+ currentKey = key; // 配列の可能性のためにキーを保持
69
120
  }
70
- // 値が空の場合は次の行で配列として処理される可能性がある
121
+ // 値が空の場合は次の行で配列または複数行として処理される
71
122
  }
72
123
  }
73
124
  }
74
125
 
126
+ // ファイル終端で複数行モードが残っている場合
127
+ if (multilineMode && currentKey && multilineBuffer.length > 0) {
128
+ let result = multilineBuffer.join('\n');
129
+
130
+ if (multilineMode === 'folded') {
131
+ result = result.replace(/\n(?!\n)/g, ' ').replace(/\n\n+/g, '\n\n');
132
+ }
133
+
134
+ if (multilineStripTrailing) {
135
+ result = result.replace(/\n+$/, '');
136
+ }
137
+
138
+ frontmatter[currentKey] = result.trim();
139
+ }
140
+
75
141
  return { frontmatter, body };
76
142
  }
77
143
 
@@ -266,6 +332,215 @@ async function renderMath(container) {
266
332
  }
267
333
  }
268
334
 
335
+ /**
336
+ * コードビューアを拡張(行番号・インデントガイド)
337
+ * .code-viewer 内の pre>code ブロックを変換する
338
+ */
339
+ function enhanceCodeViewer() {
340
+ const viewers = document.querySelectorAll('.code-viewer');
341
+ if (viewers.length === 0) return;
342
+
343
+ viewers.forEach(viewer => {
344
+ const codeBlock = viewer.querySelector('pre code');
345
+ if (!codeBlock) return;
346
+
347
+ const pre = codeBlock.parentElement;
348
+ const body = viewer.querySelector('.code-viewer-body');
349
+ if (!body) return;
350
+
351
+ // highlight.js が適用済みの HTML を取得
352
+ const highlightedHtml = codeBlock.innerHTML;
353
+
354
+ // 行に分割(highlight.js の span タグを考慮)
355
+ const lines = splitHighlightedLines(highlightedHtml);
356
+ const tabSize = detectTabSize(codeBlock.textContent);
357
+
358
+ // テーブル構造を構築
359
+ const table = document.createElement('table');
360
+ table.className = 'code-table';
361
+ table.setAttribute('role', 'presentation');
362
+
363
+ const tbody = document.createElement('tbody');
364
+
365
+ lines.forEach((lineHtml, index) => {
366
+ const tr = document.createElement('tr');
367
+ tr.className = 'code-line';
368
+
369
+ // 行番号セル
370
+ const lineNumTd = document.createElement('td');
371
+ lineNumTd.className = 'line-number';
372
+ lineNumTd.setAttribute('data-line', index + 1);
373
+ lineNumTd.textContent = index + 1;
374
+
375
+ // コード内容セル
376
+ const contentTd = document.createElement('td');
377
+ contentTd.className = 'line-content';
378
+
379
+ // インデントガイドを生成
380
+ const textContent = stripHtmlTags(lineHtml);
381
+ const indentLevel = calculateIndentLevel(textContent, tabSize);
382
+
383
+ if (indentLevel > 0) {
384
+ const guides = document.createElement('span');
385
+ guides.className = 'indent-guides';
386
+ guides.setAttribute('aria-hidden', 'true');
387
+ // 各インデントレベルのガイド線を生成
388
+ for (let i = 0; i < indentLevel; i++) {
389
+ const guide = document.createElement('span');
390
+ guide.className = 'indent-guide';
391
+ guide.style.left = (i * tabSize) + 'ch';
392
+ guides.appendChild(guide);
393
+ }
394
+ contentTd.appendChild(guides);
395
+ }
396
+
397
+ // ハイライト済みコード
398
+ const codeSpan = document.createElement('span');
399
+ codeSpan.className = 'line-code';
400
+ codeSpan.innerHTML = lineHtml || '\n';
401
+ contentTd.appendChild(codeSpan);
402
+
403
+ tr.appendChild(lineNumTd);
404
+ tr.appendChild(contentTd);
405
+ tbody.appendChild(tr);
406
+ });
407
+
408
+ table.appendChild(tbody);
409
+
410
+ // 元の pre>code を置き換え
411
+ // highlight.js のクラスを body に引き継ぐ(CSSセレクタ継承用)
412
+ const langClass = codeBlock.className || '';
413
+ if (langClass) {
414
+ langClass.split(/\s+/).forEach(cls => {
415
+ if (cls) body.classList.add(cls);
416
+ });
417
+ }
418
+
419
+ body.innerHTML = '';
420
+ body.appendChild(table);
421
+
422
+ // CSS変数でタブサイズを設定
423
+ viewer.style.setProperty('--tab-size', tabSize);
424
+ });
425
+ }
426
+
427
+ /**
428
+ * highlight.js のハイライト済み HTML を行ごとに分割
429
+ * span タグが行をまたぐ場合を考慮する
430
+ */
431
+ function splitHighlightedLines(html) {
432
+ // まず改行で分割
433
+ const rawLines = html.split('\n');
434
+
435
+ // 開いた span タグを追跡し、行をまたぐ場合に閉じ/開きを補完
436
+ const result = [];
437
+ let openTags = [];
438
+
439
+ for (let i = 0; i < rawLines.length; i++) {
440
+ let line = rawLines[i];
441
+
442
+ // 前の行から引き継いだ開きタグを先頭に追加
443
+ if (openTags.length > 0) {
444
+ line = openTags.join('') + line;
445
+ }
446
+
447
+ // この行の開きタグと閉じタグを解析
448
+ openTags = getOpenTags(line);
449
+
450
+ // 開いたままのタグがある場合、行末で閉じる
451
+ if (openTags.length > 0) {
452
+ line += '</span>'.repeat(openTags.length);
453
+ }
454
+
455
+ result.push(line);
456
+ }
457
+
458
+ // 末尾の空行を削除
459
+ while (result.length > 1 && result[result.length - 1].trim() === '') {
460
+ result.pop();
461
+ }
462
+
463
+ return result;
464
+ }
465
+
466
+ /**
467
+ * HTML 文字列内の未閉じの span タグを取得
468
+ */
469
+ function getOpenTags(html) {
470
+ const openTags = [];
471
+ const tagRegex = /<\/?span[^>]*>/g;
472
+ let match;
473
+
474
+ while ((match = tagRegex.exec(html)) !== null) {
475
+ if (match[0].startsWith('</')) {
476
+ openTags.pop();
477
+ } else {
478
+ openTags.push(match[0]);
479
+ }
480
+ }
481
+
482
+ return openTags;
483
+ }
484
+
485
+ /**
486
+ * HTML タグを除去してテキストのみを取得
487
+ */
488
+ function stripHtmlTags(html) {
489
+ const tmp = document.createElement('div');
490
+ tmp.innerHTML = html;
491
+ return tmp.textContent || '';
492
+ }
493
+
494
+ /**
495
+ * コードのタブサイズを自動検出
496
+ * 最も一般的なインデント幅を返す
497
+ */
498
+ function detectTabSize(text) {
499
+ const lines = text.split('\n');
500
+ const indents = {};
501
+
502
+ for (const line of lines) {
503
+ const match = line.match(/^( +)\S/);
504
+ if (match) {
505
+ const len = match[1].length;
506
+ indents[len] = (indents[len] || 0) + 1;
507
+ }
508
+ }
509
+
510
+ // インデント幅の最小差分を求める
511
+ const sizes = Object.keys(indents).map(Number).sort((a, b) => a - b);
512
+ if (sizes.length === 0) return 2;
513
+
514
+ // 最小のインデント幅
515
+ const minIndent = sizes[0];
516
+ if (minIndent <= 4 && minIndent >= 1) return minIndent;
517
+
518
+ return 2; // デフォルト
519
+ }
520
+
521
+ /**
522
+ * テキストのインデントレベルを計算
523
+ * @param {string} text - テキスト行
524
+ * @param {number} tabSize - タブサイズ(スペース数)
525
+ * @returns {number} インデントレベル
526
+ */
527
+ function calculateIndentLevel(text, tabSize) {
528
+ if (!text || text.trim() === '') return 0;
529
+
530
+ let spaces = 0;
531
+ for (let i = 0; i < text.length; i++) {
532
+ if (text[i] === ' ') {
533
+ spaces++;
534
+ } else if (text[i] === '\t') {
535
+ spaces += tabSize;
536
+ } else {
537
+ break;
538
+ }
539
+ }
540
+
541
+ return Math.floor(spaces / tabSize);
542
+ }
543
+
269
544
  /**
270
545
  * Mermaid 用の HTML エスケープ(最小限)
271
546
  */
@@ -281,6 +556,7 @@ function escapeHtmlForMermaid(str) {
281
556
  window.mdv = {
282
557
  renderMarkdown,
283
558
  highlightCode,
559
+ enhanceCodeViewer,
284
560
  renderMermaid,
285
561
  renderMath
286
562
  };
@@ -55,6 +55,31 @@ function removeExpandedDirectory(path) {
55
55
  saveExpandedDirectories(filtered);
56
56
  }
57
57
 
58
+ /**
59
+ * サイドバーのスクロール位置をlocalStorageに保存
60
+ */
61
+ function saveSidebarScrollPosition(scrollTop) {
62
+ try {
63
+ localStorage.setItem('sidebar-scroll-position', scrollTop.toString());
64
+ } catch {
65
+ // Storage full or unavailable
66
+ }
67
+ }
68
+
69
+ /**
70
+ * サイドバーのスクロール位置をlocalStorageから取得して復元
71
+ */
72
+ function restoreSidebarScrollPosition(element) {
73
+ try {
74
+ const savedScrollTop = localStorage.getItem('sidebar-scroll-position');
75
+ if (savedScrollTop !== null) {
76
+ element.scrollTop = parseInt(savedScrollTop, 10);
77
+ }
78
+ } catch {
79
+ // Storage unavailable
80
+ }
81
+ }
82
+
58
83
  /**
59
84
  * サイドバーナビゲーションを初期化
60
85
  */
@@ -138,6 +163,23 @@ function initSidebar() {
138
163
  // 現在のパスを取得
139
164
  const currentPath = window.location.pathname;
140
165
 
166
+ // サイドバーコンテンツのスクロール位置を復元・保存
167
+ const sidebarContent = document.querySelector('.sidebar-content');
168
+ if (sidebarContent) {
169
+ // ページロード時にスクロール位置を復元
170
+ restoreSidebarScrollPosition(sidebarContent);
171
+
172
+ // スクロール時にスクロール位置を保存
173
+ sidebarContent.addEventListener('scroll', () => {
174
+ saveSidebarScrollPosition(sidebarContent.scrollTop);
175
+ });
176
+
177
+ // ページ遷移前にスクロール位置を保存
178
+ window.addEventListener('beforeunload', () => {
179
+ saveSidebarScrollPosition(sidebarContent.scrollTop);
180
+ });
181
+ }
182
+
141
183
  // ルートディレクトリを読み込み
142
184
  loadDirectory('/', fileTree, 0).then(async () => {
143
185
  // 保存された展開状態を復元
@@ -145,6 +187,11 @@ function initSidebar() {
145
187
  for (const dirPath of expandedDirs) {
146
188
  await expandToPath(dirPath, fileTree);
147
189
  }
190
+
191
+ // ツリーの読み込み後、再度スクロール位置を復元(コンテンツが変わった場合に備えて)
192
+ if (sidebarContent) {
193
+ restoreSidebarScrollPosition(sidebarContent);
194
+ }
148
195
  });
149
196
  }
150
197
 
@@ -551,7 +598,8 @@ function initBackToTop() {
551
598
  }
552
599
 
553
600
  /**
554
- * ファイル名コピーボタンを初期化
601
+ * パスコピーボタンを初期化
602
+ * ファイル名ではなく相対パスをコピーする
555
603
  */
556
604
  function initCopyFilename() {
557
605
  const breadcrumbs = document.getElementById('breadcrumbs');
@@ -560,15 +608,19 @@ function initCopyFilename() {
560
608
  const currentSpan = breadcrumbs.querySelector('.current');
561
609
  if (!currentSpan) return;
562
610
 
563
- const filename = currentSpan.textContent.trim();
564
- if (!filename) return;
611
+ const currentName = currentSpan.textContent.trim();
612
+ if (!currentName || currentName === 'Home') return;
613
+
614
+ // 相対パスを取得(先頭の / を除去)
615
+ const relativePath = getRelativePath(window.location.pathname);
616
+ if (!relativePath) return;
565
617
 
566
618
  // コピーボタンを作成
567
619
  const copyButton = document.createElement('button');
568
620
  copyButton.id = 'copy-filename';
569
621
  copyButton.type = 'button';
570
- copyButton.title = 'Copy filename';
571
- copyButton.setAttribute('aria-label', 'Copy filename');
622
+ copyButton.title = 'Copy path: ' + relativePath;
623
+ copyButton.setAttribute('aria-label', 'Copy path');
572
624
  copyButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`;
573
625
 
574
626
  // currentSpanの後にボタンを挿入
@@ -580,12 +632,12 @@ function initCopyFilename() {
580
632
  e.stopPropagation();
581
633
 
582
634
  try {
583
- await navigator.clipboard.writeText(filename);
635
+ await navigator.clipboard.writeText(relativePath);
584
636
  showCopySuccess(copyButton);
585
637
  } catch (err) {
586
638
  // フォールバック: execCommandを使用
587
639
  const textArea = document.createElement('textarea');
588
- textArea.value = filename;
640
+ textArea.value = relativePath;
589
641
  textArea.style.position = 'fixed';
590
642
  textArea.style.left = '-9999px';
591
643
  document.body.appendChild(textArea);
@@ -601,6 +653,15 @@ function initCopyFilename() {
601
653
  });
602
654
  }
603
655
 
656
+ /**
657
+ * URLパスから相対パスを取得
658
+ * @param {string} pathname - window.location.pathname
659
+ * @returns {string} 先頭の / を除いた相対パス
660
+ */
661
+ function getRelativePath(pathname) {
662
+ return pathname.replace(/^\//, '');
663
+ }
664
+
604
665
  /**
605
666
  * コピー成功時のフィードバック表示
606
667
  */
@@ -78,6 +78,10 @@ a:hover {
78
78
  border-radius: 6px;
79
79
  margin-bottom: 24px;
80
80
  border: 1px solid var(--color-border);
81
+ position: sticky;
82
+ top: 0;
83
+ z-index: 100;
84
+ box-shadow: var(--shadow-sm);
81
85
  }
82
86
 
83
87
  #breadcrumbs a {
@@ -144,6 +148,183 @@ code {
144
148
  }
145
149
  }
146
150
 
151
+ /* ============================================
152
+ Code Viewer (VSCode-like)
153
+ ============================================ */
154
+ .code-viewer {
155
+ --cv-bg: var(--color-code-bg);
156
+ --cv-header-bg: var(--color-bg-secondary);
157
+ --cv-border: var(--color-border);
158
+ --cv-line-number-color: var(--color-text-muted);
159
+ --cv-line-number-bg: var(--color-code-bg);
160
+ --cv-line-hover-bg: rgba(128, 128, 128, 0.08);
161
+ --cv-indent-guide-color: rgba(128, 128, 128, 0.2);
162
+ --tab-size: 2;
163
+ border: 1px solid var(--cv-border);
164
+ border-radius: 8px;
165
+ overflow: hidden;
166
+ box-shadow: var(--shadow-sm);
167
+ }
168
+
169
+ .code-viewer-header {
170
+ display: flex;
171
+ align-items: center;
172
+ justify-content: space-between;
173
+ padding: 8px 16px;
174
+ background-color: var(--cv-header-bg);
175
+ border-bottom: 1px solid var(--cv-border);
176
+ font-size: 13px;
177
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
178
+ }
179
+
180
+ .code-viewer-filename {
181
+ font-weight: 600;
182
+ color: var(--color-text);
183
+ }
184
+
185
+ .code-viewer-language {
186
+ color: var(--color-text-muted);
187
+ font-size: 12px;
188
+ padding: 2px 8px;
189
+ background-color: var(--cv-bg);
190
+ border-radius: 4px;
191
+ border: 1px solid var(--cv-border);
192
+ }
193
+
194
+ .code-viewer-body {
195
+ overflow-x: auto;
196
+ background-color: var(--cv-bg);
197
+ -webkit-overflow-scrolling: touch;
198
+ }
199
+
200
+ /* Override default pre/code styles inside code-viewer */
201
+ .code-viewer pre {
202
+ margin: 0;
203
+ padding: 0;
204
+ border: none;
205
+ border-radius: 0;
206
+ box-shadow: none;
207
+ background: transparent;
208
+ }
209
+
210
+ .code-viewer pre code {
211
+ background: transparent;
212
+ padding: 0;
213
+ }
214
+
215
+ /* Code table layout */
216
+ .code-table {
217
+ width: 100%;
218
+ border-collapse: collapse;
219
+ border-spacing: 0;
220
+ border: none;
221
+ border-radius: 0;
222
+ overflow: visible;
223
+ margin: 0;
224
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
225
+ font-size: 13px;
226
+ line-height: 1.5;
227
+ tab-size: var(--tab-size);
228
+ }
229
+
230
+ .code-table tr {
231
+ background: transparent;
232
+ border: none;
233
+ }
234
+
235
+ .code-table tr:nth-child(2n) {
236
+ background: transparent;
237
+ }
238
+
239
+ .code-table th,
240
+ .code-table td {
241
+ border: none;
242
+ padding: 0;
243
+ }
244
+
245
+ .code-line:hover {
246
+ background-color: var(--cv-line-hover-bg);
247
+ }
248
+
249
+ /* Line numbers */
250
+ .line-number {
251
+ position: sticky;
252
+ left: 0;
253
+ width: 1px;
254
+ min-width: 50px;
255
+ padding: 0 12px 0 16px !important;
256
+ text-align: right;
257
+ color: var(--cv-line-number-color);
258
+ background-color: var(--cv-line-number-bg);
259
+ user-select: none;
260
+ white-space: nowrap;
261
+ vertical-align: top;
262
+ font-size: 13px;
263
+ line-height: 1.5;
264
+ border-right: 1px solid var(--cv-border);
265
+ }
266
+
267
+ .code-line:hover .line-number {
268
+ color: var(--color-text-secondary);
269
+ }
270
+
271
+ /* Line content */
272
+ .line-content {
273
+ position: relative;
274
+ padding: 0 16px 0 16px !important;
275
+ white-space: pre;
276
+ word-wrap: normal;
277
+ vertical-align: top;
278
+ font-size: 13px;
279
+ line-height: 1.5;
280
+ }
281
+
282
+ /* Indent guides */
283
+ .indent-guides {
284
+ position: absolute;
285
+ top: 0;
286
+ left: 16px;
287
+ bottom: 0;
288
+ pointer-events: none;
289
+ user-select: none;
290
+ }
291
+
292
+ .indent-guide {
293
+ position: absolute;
294
+ top: 0;
295
+ bottom: 0;
296
+ width: 1px;
297
+ background-color: var(--cv-indent-guide-color);
298
+ }
299
+
300
+ /* Line code span */
301
+ .line-code {
302
+ position: relative;
303
+ }
304
+
305
+ /* Override highlight.js background on code-viewer-body */
306
+ .code-viewer-body.hljs {
307
+ background: transparent !important;
308
+ padding: 0 !important;
309
+ }
310
+
311
+ /* Dark mode adjustments */
312
+ @media (prefers-color-scheme: dark) {
313
+ .code-viewer {
314
+ --cv-line-hover-bg: rgba(128, 128, 128, 0.1);
315
+ --cv-indent-guide-color: rgba(128, 128, 128, 0.25);
316
+ }
317
+ }
318
+
319
+ /* First and last lines padding */
320
+ .code-table tbody tr:first-child td {
321
+ padding-top: 12px !important;
322
+ }
323
+
324
+ .code-table tbody tr:last-child td {
325
+ padding-bottom: 12px !important;
326
+ }
327
+
147
328
  /* ============================================
148
329
  Directory Listing Enhancement
149
330
  ============================================ */
@@ -808,4 +989,12 @@ body.sidebar-open #sidebar-overlay {
808
989
  #main-wrapper {
809
990
  margin-left: 0 !important;
810
991
  }
992
+
993
+ .line-number {
994
+ position: static;
995
+ }
996
+
997
+ .code-viewer-body {
998
+ overflow: visible;
999
+ }
811
1000
  }
package/src/routes/raw.js CHANGED
@@ -3,12 +3,15 @@ import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
  import { validatePath } from '../utils/path.js';
5
5
  import { renderTemplate } from '../utils/template.js';
6
- import { getLanguageFromExtension, getLanguageFromFilename, isSupportedExtension } from '../utils/language.js';
6
+ import { getLanguageFromExtension, getLanguageFromFilename, getLanguageDisplayName, isBinaryExtension } from '../utils/language.js';
7
7
  import { escapeHtml } from '../utils/html.js';
8
8
  import { generateBreadcrumbs } from '../utils/navigation.js';
9
9
 
10
10
  const router = Router();
11
11
 
12
+ // 1MB - 巨大ファイルの表示を防止
13
+ const MAX_FILE_SIZE = 1 * 1024 * 1024;
14
+
12
15
  /**
13
16
  * Raw コード表示ルート
14
17
  * 非 Markdown ファイルをシンタックスハイライト付きで表示
@@ -23,14 +26,13 @@ router.get(/^\/.*/, async (req, res, next) => {
23
26
  return next();
24
27
  }
25
28
 
26
- const fileName = path.basename(requestPath);
27
- const filenameLanguage = getLanguageFromFilename(fileName);
28
-
29
- // サポートされていない拡張子かつファイル名でも判定できない場合は次のハンドラへ
30
- if (!isSupportedExtension(ext) && !filenameLanguage) {
29
+ // バイナリファイルは次のハンドラへ
30
+ if (isBinaryExtension(ext)) {
31
31
  return next();
32
32
  }
33
33
 
34
+ const fileName = path.basename(requestPath);
35
+
34
36
  const docRoot = req.app.get('docRoot');
35
37
  let filePath;
36
38
 
@@ -56,16 +58,35 @@ router.get(/^\/.*/, async (req, res, next) => {
56
58
  return next();
57
59
  }
58
60
 
59
- // ファイル読み込み
60
- const content = await fs.readFile(filePath, 'utf-8');
61
- // 拡張子ベースの言語判定を優先し、なければファイル名ベース
62
- const language = isSupportedExtension(ext) ? getLanguageFromExtension(ext) : (filenameLanguage || 'plaintext');
61
+ // ファイルサイズ制限チェック
62
+ if (stat.size > MAX_FILE_SIZE) {
63
+ return next();
64
+ }
65
+
66
+ // ファイル読み込み(非UTF-8ファイルを安全にスキップ)
67
+ let content;
68
+ try {
69
+ content = await fs.readFile(filePath, 'utf-8');
70
+ } catch {
71
+ return next();
72
+ }
73
+ // ファイル名ベースの判定を優先し、なければ拡張子ベース(未知の拡張子は plaintext)
74
+ const filenameLanguage = getLanguageFromFilename(fileName);
75
+ const language = filenameLanguage || getLanguageFromExtension(ext);
76
+ const languageDisplayName = getLanguageDisplayName(language);
63
77
 
64
78
  const docRootName = req.app.get('docRootName');
65
79
  const html = renderTemplate('page', {
66
80
  title: `${fileName} - ${docRootName}`,
67
- content: `<h1>${escapeHtml(fileName)}</h1>
68
- <pre><code class="language-${language}">${escapeHtml(content)}</code></pre>`,
81
+ content: `<div class="code-viewer" data-file-path="${escapeHtml(requestPath)}">
82
+ <div class="code-viewer-header">
83
+ <span class="code-viewer-filename">${escapeHtml(fileName)}</span>
84
+ <span class="code-viewer-language">${escapeHtml(languageDisplayName)}</span>
85
+ </div>
86
+ <div class="code-viewer-body">
87
+ <pre><code class="language-${language}">${escapeHtml(content)}</code></pre>
88
+ </div>
89
+ </div>`,
69
90
  breadcrumbs: generateBreadcrumbs(requestPath)
70
91
  });
71
92
 
@@ -26,13 +26,32 @@ export function getLanguageFromFilename(filename) {
26
26
  // Shell config
27
27
  '.bashrc': 'bash',
28
28
  '.bash_profile': 'bash',
29
- '.zshrc': 'zsh',
29
+ '.zshrc': 'bash',
30
30
  '.profile': 'bash',
31
31
 
32
32
  // Node.js
33
33
  '.npmrc': 'ini',
34
34
  '.nvmrc': 'plaintext',
35
35
 
36
+ // Config (JSON-based)
37
+ '.prettierrc': 'json',
38
+ '.eslintrc': 'json',
39
+ '.babelrc': 'json',
40
+
41
+ // Env files
42
+ '.env': 'bash',
43
+ '.env.local': 'bash',
44
+ '.env.example': 'bash',
45
+ '.env.development': 'bash',
46
+ '.env.production': 'bash',
47
+ '.env.test': 'bash',
48
+
49
+ // Ignore files
50
+ '.prettierignore': 'plaintext',
51
+ '.eslintignore': 'plaintext',
52
+ '.dockerignore': 'plaintext',
53
+ '.npmignore': 'plaintext',
54
+
36
55
  // Misc
37
56
  'Vagrantfile': 'ruby',
38
57
  'Rakefile': 'ruby',
@@ -106,7 +125,7 @@ export function getLanguageFromExtension(ext) {
106
125
  // Shell
107
126
  '.sh': 'bash',
108
127
  '.bash': 'bash',
109
- '.zsh': 'zsh',
128
+ '.zsh': 'bash',
110
129
  '.fish': 'shell',
111
130
 
112
131
  // SQL
@@ -149,3 +168,76 @@ export function isSupportedExtension(ext) {
149
168
 
150
169
  return supportedExtensions.includes(ext.toLowerCase());
151
170
  }
171
+
172
+ /**
173
+ * バイナリファイルの拡張子かどうかをチェック
174
+ * @param {string} ext - ファイル拡張子(.付き)
175
+ * @returns {boolean}
176
+ */
177
+ export function isBinaryExtension(ext) {
178
+ const binaryExtensions = [
179
+ // 画像
180
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.ico', '.webp', '.tiff', '.tif',
181
+ // 動画
182
+ '.mp4', '.avi', '.mov', '.webm', '.mkv', '.flv', '.wmv',
183
+ // 音声
184
+ '.mp3', '.wav', '.ogg', '.flac', '.aac', '.wma',
185
+ // 圧縮
186
+ '.zip', '.tar', '.gz', '.7z', '.rar', '.bz2', '.xz',
187
+ // 実行・ライブラリ
188
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.class',
189
+ // フォント
190
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
191
+ // ドキュメント(バイナリ形式)
192
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
193
+ // データベース
194
+ '.sqlite', '.db',
195
+ // その他
196
+ '.DS_Store',
197
+ ];
198
+
199
+ return binaryExtensions.includes(ext.toLowerCase());
200
+ }
201
+
202
+ /**
203
+ * highlight.js 言語名から表示用の言語名を取得
204
+ * @param {string} language - highlight.js の言語名
205
+ * @returns {string} - 表示用の言語名
206
+ */
207
+ export function getLanguageDisplayName(language) {
208
+ if (!language) return 'Plain Text';
209
+
210
+ const displayNames = {
211
+ 'javascript': 'JavaScript',
212
+ 'typescript': 'TypeScript',
213
+ 'python': 'Python',
214
+ 'json': 'JSON',
215
+ 'html': 'HTML',
216
+ 'css': 'CSS',
217
+ 'scss': 'SCSS',
218
+ 'less': 'Less',
219
+ 'xml': 'XML',
220
+ 'yaml': 'YAML',
221
+ 'toml': 'TOML',
222
+ 'bash': 'Bash',
223
+ 'zsh': 'Zsh',
224
+ 'shell': 'Shell',
225
+ 'sql': 'SQL',
226
+ 'php': 'PHP',
227
+ 'cpp': 'C++',
228
+ 'c': 'C',
229
+ 'go': 'Go',
230
+ 'rust': 'Rust',
231
+ 'java': 'Java',
232
+ 'ruby': 'Ruby',
233
+ 'erb': 'ERB',
234
+ 'makefile': 'Makefile',
235
+ 'dockerfile': 'Dockerfile',
236
+ 'cmake': 'CMake',
237
+ 'ini': 'INI',
238
+ 'markdown': 'Markdown',
239
+ 'plaintext': 'Plain Text'
240
+ };
241
+
242
+ return displayNames[language] || language.charAt(0).toUpperCase() + language.slice(1);
243
+ }
@@ -12,7 +12,11 @@
12
12
  <link rel="stylesheet" href="/static/styles/base.css">
13
13
  <link rel="stylesheet" href="/static/styles/modern.css">
14
14
  <link rel="stylesheet"
15
- href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github.min.css">
15
+ href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github.min.css"
16
+ media="(prefers-color-scheme: light)">
17
+ <link rel="stylesheet"
18
+ href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css"
19
+ media="(prefers-color-scheme: dark)">
16
20
 
17
21
  <!-- MathJax 設定 -->
18
22
  <script>
@@ -100,7 +104,8 @@
100
104
 
101
105
  <!-- クライアント側ライブラリ -->
102
106
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
103
- <script src="https://cdn.jsdelivr.net/npm/highlight.js@11"></script>
107
+ <script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
108
+ <script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/languages/dockerfile.min.js"></script>
104
109
  <script type="module">
105
110
  import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
106
111
  window.mermaid = mermaid;