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 +32 -0
- package/package.json +1 -1
- package/public/js/app.js +281 -5
- package/public/js/navigation.js +68 -7
- package/public/styles/modern.css +189 -0
- package/src/routes/raw.js +33 -12
- package/src/utils/language.js +94 -2
- package/templates/page.html +7 -2
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
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
|
-
|
|
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
|
};
|
package/public/js/navigation.js
CHANGED
|
@@ -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
|
|
564
|
-
if (!
|
|
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
|
|
571
|
-
copyButton.setAttribute('aria-label', 'Copy
|
|
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(
|
|
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 =
|
|
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
|
*/
|
package/public/styles/modern.css
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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: `<
|
|
68
|
-
|
|
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
|
|
package/src/utils/language.js
CHANGED
|
@@ -26,13 +26,32 @@ export function getLanguageFromFilename(filename) {
|
|
|
26
26
|
// Shell config
|
|
27
27
|
'.bashrc': 'bash',
|
|
28
28
|
'.bash_profile': 'bash',
|
|
29
|
-
'.zshrc': '
|
|
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': '
|
|
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
|
+
}
|
package/templates/page.html
CHANGED
|
@@ -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/
|
|
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
|
|
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;
|