読み込み中...

HTMLビューアの作り方と応用例11選!初心者でも簡単にマスタ

HTMLビューアの作り方と応用例を初心者向けに解説 HTML
この記事は約161分で読めます。

【サイト内のコードはご自由に個人利用・商用利用いただけます】

この記事では、プログラムの基礎知識を前提に話を進めています。

説明のためのコードや、サンプルコードもありますので、もちろん初心者でも理解できるように表現してあります。

本記事のサンプルコードを活用して機能追加、目的を達成できるように作ってありますので、是非ご活用ください。

※この記事は、一般的にプロフェッショナルの指標とされる『実務経験10,000時間以上』を満たす現役のプログラマチームによって監修されています。

📋 対応バージョン
HTML HTML5
CSS CSS3
JavaScript ES6+ (ES2015+)
Prism.js 9.0.0
jschardet 3.1.1
Electron 12+
Node.js 12+
Chrome 60+
Firefox 55+
Safari 12+
Edge 79+
IE 11
完全対応 一部機能制限

※Japanシーモアは、常に解説内容のわかりやすさや記事の品質に注力しております。不具合、分かりにくい説明や不適切な表現、動かないコードなど気になることがございましたら、記事の品質向上の為にお問い合わせフォームにてご共有いただけますと幸いです。
(送信された情報は、プライバシーポリシーのもと、厳正に取扱い、処分させていただきます。)

はじめに

HTMLビューアは、ウェブ開発者にとって欠かせないツールの一つです。

この記事では、HTMLビューアの作り方から使い方まで、初心者でも理解しやすいように丁寧に解説していきます。

さらに、応用例やサンプルコードを通じて、実践的なスキルを身につけることができるでしょう。

ウェブ開発に踏み出したばかりの方も、経験豊富な開発者も、この記事から新たな知識やアイデアを得ることができるはずです。

●HTMLビューアとは

HTMLビューアは、HTMLファイルを安全に表示し、解析し、編集するための強力なツールです。

単にブラウザでHTMLを閲覧するだけでなく、開発者向けの高度な機能も備えています。

例えば、リアルタイムでのコード編集やデバッグ、さらにはクロスブラウザテストなどが可能です。

HTMLビューアを使いこなすことで、ウェブ開発の効率が大幅に向上し、より洗練されたウェブサイトやアプリケーションを作成することができるのです。

●HTMLビューアの作り方

HTMLビューアを自作することで、自分のニーズに合わせたカスタマイズが可能になります。

また、その過程でHTMLやJavaScript、CSSなどのウェブ技術への理解も深まります。

ここでは、セキュリティを考慮したHTMLビューアを作成するための手順を、順を追って説明していきます。

○環境設定

HTMLビューアを作成する前に、適切な開発環境を整えることが重要です。

まず、テキストエディタやIDE(統合開発環境)を選択しましょう。

Visual Studio CodeやSublime Textなどが人気です。

次に、Node.jsをインストールします。

これで、npm(Node Package Manager)を使用して必要なライブラリやツールを簡単にインストールできます。

また、Git for version controlをセットアップすることで、コードの変更履歴を管理できるようになります。

○基本的なHTML構造

HTMLビューアを作成する上で、HTMLの基本構造を理解することは不可欠です。

HTMLは、文書の構造を定義するためのマークアップ言語です。

要素はタグで囲まれ、開始タグと終了タグの間にコンテンツが配置されます。例えば、<p>これは段落です。</p>のように使用します。

また、HTML文書全体は<!DOCTYPE html>宣言で始まり、<html>タグで囲まれます。

その中に<head><body>セクションがあり、それぞれメタデータと実際のコンテンツを含みます。

○ビューアの作成

HTMLビューアの核心部分は、JavaScriptを使用してファイルを読み込み、安全に解析し、表示する機能です。

ファイルの読み込みには、ファイルAPIを使用してローカルファイルを扱うか、XMLHttpRequestやFetch APIを使用してリモートファイルを取得します。

ここでは、セキュリティを考慮したHTMLビューアの基本構造を紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>セキュアHTMLビューア</title>
    <style>
        #fileInput { margin-bottom: 10px; }
        #viewer { 
            width: 100%; 
            height: 500px; 
            border: 1px solid #ccc; 
            padding: 10px;
            font-family: monospace;
            overflow: auto;
            white-space: pre-wrap;
        }
        #error { color: red; margin-top: 10px; }
    </style>
</head>
<body>
    <input type="file" id="fileInput" accept=".html,.htm">
    <div id="viewer"></div>
    <div id="error"></div>
    <script>
        const fileInput = document.getElementById('fileInput');
        const viewer = document.getElementById('viewer');
        const errorDiv = document.getElementById('error');

        // HTMLエスケープ関数
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }

        fileInput.addEventListener('change', (event) => {
            const file = event.target.files[0];
            
            if (!file) {
                errorDiv.textContent = 'ファイルが選択されていません。';
                return;
            }

            if (!file.name.match(/\.(html|htm)$/i)) {
                errorDiv.textContent = 'HTMLファイルを選択してください。';
                return;
            }

            const reader = new FileReader();

            reader.onload = (e) => {
                try {
                    // XSS攻撃を防ぐため、HTMLをエスケープして表示
                    viewer.textContent = e.target.result;
                    errorDiv.textContent = '';
                } catch (error) {
                    errorDiv.textContent = `エラーが発生しました: ${error.message}`;
                }
            };

            reader.onerror = (e) => {
                errorDiv.textContent = `ファイルの読み込み中にエラーが発生しました: ${e.target.error}`;
            };

            reader.readAsText(file);
        });
    </script>
</body>
</html>

この例では、ファイル選択インターフェースを提供し、選択されたHTMLファイルの内容を安全にビューア領域に表示します。重要なポイントは、XSS攻撃を防ぐためにtextContentを使用してHTMLをエスケープしていることです。

○カスタマイズの方法

HTMLビューアの基本機能ができたら、次はカスタマイズです。CSSを使用してデザインを改善し、JavaScriptで追加機能を実装できます。

例えば、シンタックスハイライトを追加するには、最新バージョンのPrism.jsやHighlight.jsなどのライブラリを使用できます。

また、エディタ機能を追加するには、CodeMirrorやAce Editorなどのライブラリが役立ちます。

ここでは、セキュリティを考慮したシンタックスハイライトを追加する例を紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>シンタックスハイライト付きHTMLビューア</title>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/9.0.0/themes/prism.min.css" rel="stylesheet" />
    <style>
        #fileInput { margin-bottom: 10px; }
        #viewer { width: 100%; height: 500px; border: 1px solid #ccc; overflow: auto; }
        #error { color: red; margin-top: 10px; }
    </style>
</head>
<body>
    <input type="file" id="fileInput" accept=".html,.htm">
    <pre><code id="viewer" class="language-html"></code></pre>
    <div id="error"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/9.0.0/prism.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/9.0.0/components/prism-markup.min.js"></script>
    <script>
        const fileInput = document.getElementById('fileInput');
        const viewer = document.getElementById('viewer');
        const errorDiv = document.getElementById('error');

        fileInput.addEventListener('change', (event) => {
            const file = event.target.files[0];
            
            if (!file) {
                errorDiv.textContent = 'ファイルが選択されていません。';
                return;
            }

            if (!file.name.match(/\.(html|htm)$/i)) {
                errorDiv.textContent = 'HTMLファイルを選択してください。';
                return;
            }

            const reader = new FileReader();

            reader.onload = (e) => {
                try {
                    // セキュリティを考慮してtextContentを使用
                    viewer.textContent = e.target.result;
                    // Prismによるシンタックスハイライトを適用
                    Prism.highlightElement(viewer);
                    errorDiv.textContent = '';
                } catch (error) {
                    errorDiv.textContent = `エラーが発生しました: ${error.message}`;
                }
            };

            reader.onerror = (e) => {
                errorDiv.textContent = `ファイルの読み込み中にエラーが発生しました: ${e.target.error}`;
            };

            reader.readAsText(file);
        });
    </script>
</body>
</html>

このコードでは、最新バージョンのPrism.jsを使用してHTMLコードに安全なシンタックスハイライトを適用しています。

●使い方と注意点

HTMLビューアを効果的に使用するためには、いくつかの重要な点に注意する必要があります。

ここでは、ファイルの読み込み、表示の調整、デバッグとエラー対処について詳しく説明します。

○ファイルの読み込み

HTMLファイルを正確に読み込むためには、ファイルの形式と文字コードに注意を払う必要があります。

多くのHTMLファイルはUTF-8でエンコードされていますが、古いファイルではShift_JISやEUC-JPなどの文字コードが使用されていることがあります。

ファイルの文字コードを正しく認識し、適切にデコードすることが重要です。

ここでは、文字コードを自動検出する機能を追加し、セキュリティを考慮したコード例を紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文字コード自動検出HTMLビューア</title>
    <style>
        #fileInput { margin-bottom: 10px; }
        #viewer { 
            width: 100%; 
            height: 500px; 
            border: 1px solid #ccc;
            padding: 10px;
            font-family: monospace;
            overflow: auto;
            white-space: pre-wrap;
        }
        #error { color: red; margin-top: 10px; }
        #encoding { margin-top: 10px; font-weight: bold; }
    </style>
</head>
<body>
    <input type="file" id="fileInput" accept=".html,.htm">
    <div id="viewer"></div>
    <div id="encoding"></div>
    <div id="error"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jschardet/3.1.1/jschardet.min.js"></script>
    <script>
        const fileInput = document.getElementById('fileInput');
        const viewer = document.getElementById('viewer');
        const errorDiv = document.getElementById('error');
        const encodingDiv = document.getElementById('encoding');

        fileInput.addEventListener('change', (event) => {
            const file = event.target.files[0];
            
            if (!file) {
                errorDiv.textContent = 'ファイルが選択されていません。';
                return;
            }

            if (!file.name.match(/\.(html|htm)$/i)) {
                errorDiv.textContent = 'HTMLファイルを選択してください。';
                return;
            }

            const reader = new FileReader();

            reader.onload = (e) => {
                try {
                    const content = e.target.result;
                    const detected = jschardet.detect(content);
                    
                    // 文字コード検出の信頼性をチェック
                    if (detected.confidence < 0.7) {
                        errorDiv.textContent = '文字コードの検出に失敗しました。UTF-8として処理します。';
                        detected.encoding = 'UTF-8';
                    }
                    
                    const decoder = new TextDecoder(detected.encoding);
                    const decodedContent = decoder.decode(new Uint8Array(content));
                    
                    // セキュリティを考慮してtextContentを使用
                    viewer.textContent = decodedContent;
                    encodingDiv.textContent = `検出された文字コード: ${detected.encoding} (信頼度: ${Math.round(detected.confidence * 100)}%)`;
                    errorDiv.textContent = '';
                } catch (error) {
                    errorDiv.textContent = `エラーが発生しました: ${error.message}`;
                }
            };

            reader.onerror = (e) => {
                errorDiv.textContent = `ファイルの読み込み中にエラーが発生しました: ${e.target.error}`;
            };

            reader.readAsArrayBuffer(file);
        });
    </script>
</body>
</html>

このコードでは、jschardetライブラリを使用して文字コードを自動検出し、信頼性もチェックしています。また、検出に失敗した場合の適切な処理も実装されています。

○表示の調整

HTMLビューアでコンテンツを表示する際、ビューポートやズーム設定に注意を払うことで、より見やすい表示を実現できます。

レスポンシブデザインのウェブサイトをテストする場合、異なる画面サイズでの表示を確認することが重要です。

ここでは、セキュリティを考慮したビューポートサイズ変更機能を追加したコード例を紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ビューポート調整可能HTMLビューア</title>
    <style>
        #fileInput, #viewportSize { margin-bottom: 10px; }
        #viewer { border: 1px solid #ccc; background: white; }
        #error { color: red; margin-top: 10px; }
        .controls { margin-bottom: 15px; }
    </style>
</head>
<body>
    <div class="controls">
        <input type="file" id="fileInput" accept=".html,.htm">
        <select id="viewportSize">
            <option value="100%">デスクトップ (100%)</option>
            <option value="768px">タブレット (768px)</option>
            <option value="375px">スマートフォン (375px)</option>
        </select>
    </div>
    <iframe id="viewer" width="100%" height="500px" sandbox="allow-same-origin"></iframe>
    <div id="error"></div>
    <script>
        const fileInput = document.getElementById('fileInput');
        const viewportSize = document.getElementById('viewportSize');
        const viewer = document.getElementById('viewer');
        const errorDiv = document.getElementById('error');

        // HTMLをエスケープする関数
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }

        fileInput.addEventListener('change', (event) => {
            const file = event.target.files[0];
            
            if (!file) {
                errorDiv.textContent = 'ファイルが選択されていません。';
                return;
            }

            if (!file.name.match(/\.(html|htm)$/i)) {
                errorDiv.textContent = 'HTMLファイルを選択してください。';
                return;
            }

            const reader = new FileReader();

            reader.onload = (e) => {
                try {
                    const content = e.target.result;
                    // セキュリティを考慮してHTMLをエスケープ
                    const escapedContent = escapeHtml(content);
                    viewer.srcdoc = escapedContent;
                    errorDiv.textContent = '';
                } catch (error) {
                    errorDiv.textContent = `エラーが発生しました: ${error.message}`;
                }
            };

            reader.onerror = (e) => {
                errorDiv.textContent = `ファイルの読み込み中にエラーが発生しました: ${e.target.error}`;
            };

            reader.readAsText(file);
        });

        viewportSize.addEventListener('change', (event) => {
            viewer.style.width = event.target.value;
        });
    </script>
</body>
</html>

このコードでは、ドロップダウンメニューを使用してビューポートのサイズを変更できるようになっており、iframeにsandbox属性を追加してセキュリティを強化しています。

○デバッグとエラー対処法

HTMLビューアを使用してデバッグを行う際、コンソールやエラーメッセージを活用することが重要です。

多くのブラウザには開発者ツールが組み込まれており、これを使用してJavaScriptのエラーやネットワークの問題を特定できます。

また、HTMLビューアに独自のデバッグ機能を追加することも可能です。

ここでは、セキュリティを考慮した包括的なエラーログ機能を追加したコード例を紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>デバッグ機能付きHTMLビューア</title>
    <style>
        #fileInput { margin-bottom: 10px; }
        #viewer, #errorLog { 
            width: 100%; 
            height: 300px; 
            border: 1px solid #ccc; 
            margin-bottom: 10px; 
            overflow: auto;
            padding: 10px;
            box-sizing: border-box;
        }
        #viewer {
            font-family: monospace;
            white-space: pre-wrap;
        }
        #errorLog {
            background-color: #f5f5f5;
            font-family: monospace;
            font-size: 12px;
        }
        .error { color: red; }
        .success { color: green; }
        .info { color: blue; }
    </style>
</head>
<body>
    <input type="file" id="fileInput" accept=".html,.htm">
    <div id="viewer"></div>
    <div id="errorLog"></div>
    <script>
        const fileInput = document.getElementById('fileInput');
        const viewer = document.getElementById('viewer');
        const errorLog = document.getElementById('errorLog');

        // ログ出力関数
        function log(message, type = 'info') {
            const timestamp = new Date().toLocaleTimeString();
            const logEntry = document.createElement('div');
            logEntry.className = type;
            logEntry.textContent = `[${timestamp}] ${message}`;
            errorLog.appendChild(logEntry);
            errorLog.scrollTop = errorLog.scrollHeight;
        }

        // HTMLバリデーション関数(基本的なチェック)
        function validateHtml(content) {
            const issues = [];
            
            // DOCTYPE宣言のチェック
            if (!content.trim().toLowerCase().startsWith('<!doctype')) {
                issues.push('DOCTYPE宣言が見つかりません');
            }
            
            // 基本的なタグの対応チェック
            const openTags = content.match(/<(\w+)[^>]*>/g) || [];
            const closeTags = content.match(/<\/(\w+)>/g) || [];
            
            if (openTags.length !== closeTags.length) {
                issues.push('開始タグと終了タグの数が一致しません');
            }
            
            return issues;
        }

        fileInput.addEventListener('change', (event) => {
            const file = event.target.files[0];
            
            // ログをクリア
            errorLog.innerHTML = '';
            
            if (!file) {
                log('ファイルが選択されていません', 'error');
                return;
            }

            if (!file.name.match(/\.(html|htm)$/i)) {
                log('HTMLファイルを選択してください', 'error');
                return;
            }

            log(`ファイル読み込み開始: ${file.name} (${Math.round(file.size / 1024)}KB)`, 'info');

            const reader = new FileReader();

            reader.onload = (e) => {
                try {
                    const content = e.target.result;
                    
                    // HTMLバリデーション
                    const validationIssues = validateHtml(content);
                    if (validationIssues.length > 0) {
                        validationIssues.forEach(issue => log(issue, 'error'));
                    } else {
                        log('HTMLの基本構造は正常です', 'success');
                    }
                    
                    // セキュリティを考慮してtextContentを使用
                    viewer.textContent = content;
                    log('ファイルが正常に読み込まれました', 'success');
                    
                } catch (error) {
                    log(`エラーが発生しました: ${error.message}`, 'error');
                }
            };

            reader.onerror = (e) => {
                log(`ファイルの読み込み中にエラーが発生しました: ${e.target.error}`, 'error');
            };

            reader.readAsText(file);
        });

        // グローバルエラーハンドラー
        window.onerror = function(message, source, lineno, colno, error) {
            log(`JavaScriptエラー: ${message} (行: ${lineno}, 列: ${colno})`, 'error');
            return true; // エラーを処理済みとしてマーク
        };

        // Promise拒否のハンドラー
        window.addEventListener('unhandledrejection', function(event) {
            log(`未処理のPromise拒否: ${event.reason}`, 'error');
        });
    </script>
</body>
</html>

このコードでは、ファイルの読み込みエラー、JavaScriptの実行エラー、基本的なHTMLバリデーションを包括的に処理し、セキュリティを考慮した実装となっています。

●応用例とサンプルコード

HTMLビューアの基本的な機能を理解したら、さまざまな応用例を考えることができます。

ここでは、セキュリティを重視した実践的な応用例とそのサンプルコードを紹介します。

○オンラインエディタ

HTMLビューアの機能を拡張して、オンラインでHTMLを編集し、安全にリアルタイムでプレビューできるエディタを作成できます。

これで、ブラウザ上で直接HTMLコードを書き、即座に結果を確認することができます。

このような機能は、初心者がHTMLを学ぶ際や、開発者が素早くプロトタイプを作成する際に非常に有用です。

ここでは、セキュリティを考慮したオンラインHTMLエディタのサンプルコードを紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>セキュアオンラインHTMLエディタ</title>
    <style>
        body { 
            display: flex; 
            height: 100vh; 
            margin: 0; 
            font-family: Arial, sans-serif;
        }
        #editor, #preview { 
            width: 50%; 
            height: 100%; 
            box-sizing: border-box;
        }
        #editor { 
            font-family: 'Courier New', monospace; 
            font-size: 14px; 
            padding: 10px;
            border: none;
            resize: none;
            outline: none;
        }
        #preview {
            border-left: 1px solid #ccc;
            background: white;
        }
        .toolbar {
            background: #f0f0f0;
            padding: 10px;
            border-bottom: 1px solid #ccc;
            font-size: 12px;
        }
        .btn {
            padding: 5px 10px;
            margin-right: 5px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 3px;
            cursor: pointer;
        }
        .btn:hover {
            background: #0056b3;
        }
    </style>
</head>
<body>
    <div style="width: 50%;">
        <div class="toolbar">
            <button class="btn" onclick="clearEditor()">クリア</button>
            <button class="btn" onclick="insertTemplate()">テンプレート挿入</button>
            <span style="margin-left: 20px;">リアルタイムプレビュー有効</span>
        </div>
        <textarea id="editor" placeholder="HTMLコードをここに入力してください..."><!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>サンプルページ</title>
</head>
<body>
    <h1>こんにちは、世界!</h1>
    <p>これはサンプルテキストです。</p>
</body>
</html></textarea>
    </div>
    <iframe id="preview" sandbox="allow-same-origin"></iframe>
    <script>
        const editor = document.getElementById('editor');
        const preview = document.getElementById('preview');
        let updateTimeout;

        // HTMLをエスケープする関数
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }

        // セキュアなプレビュー更新関数
        function updatePreview() {
            // デバウンス処理で頻繁な更新を制限
            clearTimeout(updateTimeout);
            updateTimeout = setTimeout(() => {
                try {
                    const content = editor.value;
                    
                    // 基本的なセキュリティチェック
                    if (content.includes('<script>') || content.includes('javascript:')) {
                        preview.srcdoc = escapeHtml('セキュリティ上の理由により、スクリプトを含むコンテンツは表示できません。');
                        return;
                    }
                    
                    // サンドボックス化されたiframeで安全に表示
                    preview.srcdoc = content;
                } catch (error) {
                    preview.srcdoc = escapeHtml(`エラー: ${error.message}`);
                }
            }, 500);
        }

        // エディタのイベントリスナー
        editor.addEventListener('input', updatePreview);
        editor.addEventListener('paste', updatePreview);

        // ツールバー関数
        function clearEditor() {
            editor.value = '';
            updatePreview();
        }

        function insertTemplate() {
            const template = `<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>新しいページ</title>
    <style>
        body { font-family: Arial, sans-serif; }
    </style>
</head>
<body>
    <header>
        <h1>ページタイトル</h1>
    </header>
    <main>
        <p>コンテンツをここに追加してください。</p>
    </main>
</body>
</html>`;
            editor.value = template;
            updatePreview();
        }

        // 初期プレビューを表示
        updatePreview();
    </script>
</body>
</html>

このコードでは、画面を左右に分割し、左側にHTMLを入力するためのテキストエリア、右側にセキュアなプレビューを表示するiframeを配置しています。スクリプトタグやJavaScript URLのチェックを行い、セキュリティリスクを軽減しています。

○プレビューアプリ

HTMLビューアの概念をデスクトップアプリケーションとして実装することで、オフライン環境でも使用でき、より柔軟な操作が可能になります。

Electronなどのフレームワークを利用することで、Webアプリケーションをデスクトップアプリケーションとしてパッケージ化できます。

これで、ファイルシステムへの直接アクセスやシステムレベルの機能を利用することが可能になります。

ここでは、セキュリティを考慮したElectronを使用したHTMLプレビューアアプリのサンプルコードを紹介します。

main.js

const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const fs = require('fs');
const path = require('path');

function createWindow() {
    const win = new BrowserWindow({
        width: 1200,
        height: 800,
        webPreferences: {
            nodeIntegration: false,
            contextIsolation: true,
            enableRemoteModule: false,
            preload: path.join(__dirname, 'preload.js')
        }
    });

    win.loadFile('index.html');
}

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') {
        app.quit();
    }
});

app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
        createWindow();
    }
});

// セキュアなファイル読み込み
ipcMain.handle('open-file', async () => {
    try {
        const { filePaths } = await dialog.showOpenDialog({
            properties: ['openFile'],
            filters: [
                { name: 'HTML Files', extensions: ['html', 'htm'] },
                { name: 'All Files', extensions: ['*'] }
            ]
        });

        if (filePaths && filePaths.length > 0) {
            const filePath = filePaths[0];
            
            // ファイルサイズをチェック(10MB制限)
            const stats = fs.statSync(filePath);
            if (stats.size > 10 * 1024 * 1024) {
                throw new Error('ファイルサイズが大きすぎます(10MB制限)');
            }
            
            const content = fs.readFileSync(filePath, 'utf8');
            return {
                success: true,
                content: content,
                filename: path.basename(filePath),
                size: stats.size
            };
        }

        return { success: false, error: 'ファイルが選択されませんでした' };
    } catch (error) {
        return { success: false, error: error.message };
    }
});

preload.js

const { contextBridge, ipcRenderer } = require('electron');

// セキュアなAPIを公開
contextBridge.exposeInMainWorld('electronAPI', {
    openFile: () => ipcRenderer.invoke('open-file')
});

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>セキュアHTMLプレビューアアプリ</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 20px;
            background: #f5f5f5;
        }
        .toolbar {
            background: white;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }
        .btn {
            padding: 10px 20px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 3px;
            cursor: pointer;
            margin-right: 10px;
        }
        .btn:hover {
            background: #0056b3;
        }
        #fileInfo {
            background: #e9ecef;
            padding: 10px;
            border-radius: 3px;
            margin: 10px 0;
            font-size: 12px;
        }
        #preview {
            width: 100%;
            height: 600px;
            border: 1px solid #ccc;
            background: white;
            border-radius: 5px;
        }
        .error {
            color: red;
            background: #f8d7da;
            padding: 10px;
            border-radius: 3px;
            margin: 10px 0;
        }
    </style>
</head>
<body>
    <div class="toolbar">
        <button id="openFile" class="btn">ファイルを開く</button>
        <div id="fileInfo" style="display: none;"></div>
        <div id="error" style="display: none;"></div>
    </div>
    <iframe id="preview" sandbox="allow-same-origin"></iframe>

    <script>
        const openFileBtn = document.getElementById('openFile');
        const preview = document.getElementById('preview');
        const fileInfo = document.getElementById('fileInfo');
        const errorDiv = document.getElementById('error');

        // HTMLをエスケープする関数
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }

        // エラー表示関数
        function showError(message) {
            errorDiv.textContent = message;
            errorDiv.style.display = 'block';
            fileInfo.style.display = 'none';
        }

        // ファイル情報表示関数
        function showFileInfo(filename, size) {
            fileInfo.innerHTML = `ファイル: ${escapeHtml(filename)} | サイズ: ${Math.round(size / 1024)}KB`;
            fileInfo.style.display = 'block';
            errorDiv.style.display = 'none';
        }

        openFileBtn.addEventListener('click', async () => {
            try {
                const result = await window.electronAPI.openFile();
                
                if (result.success) {
                    // セキュリティチェック
                    if (result.content.includes('<script>') || 
                        result.content.includes('javascript:') ||
                        result.content.includes('data:')) {
                        showError('セキュリティ上の理由により、スクリプトまたはデータURLを含むコンテンツは表示できません。');
                        return;
                    }
                    
                    preview.srcdoc = result.content;
                    showFileInfo(result.filename, result.size);
                } else {
                    showError(result.error);
                }
            } catch (error) {
                showError(`エラーが発生しました: ${error.message}`);
            }
        });
    </script>
</body>
</html>

このアプリケーションでは、「ファイルを開く」ボタンをクリックすると、ファイル選択ダイアログが表示され、選択したHTMLファイルの内容がセキュアにプレビューされます。contextBridgeを使用してセキュリティを強化し、ファイルサイズ制限やコンテンツのセキュリティチェックを実装しています。

○レスポンシブデザインチェック

現代のWeb開発において、レスポンシブデザインは非常に重要です。

HTMLビューアを拡張して、異なる画面サイズでのレイアウトを安全かつ簡単にチェックできる機能を追加することで、開発者はより効率的にレスポンシブデザインを実装し、テストすることができます。

ここでは、セキュリティを考慮した複数の画面サイズでプレビューできるHTMLビューアのサンプルコードを紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>セキュアレスポンシブデザインチェッカー</title>
    <style>
        body { 
            font-family: Arial, sans-serif; 
            margin: 0;
            padding: 20px;
            background: #f5f5f5;
        }
        .toolbar {
            background: white;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }
        #fileInput { 
            margin-bottom: 15px; 
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 3px;
        }
        .preview-container { 
            display: flex; 
            flex-wrap: wrap; 
            gap: 20px;
            justify-content: center;
        }
        .preview-item { 
            background: white;
            border-radius: 8px;
            padding: 15px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            text-align: center;
        }
        .preview-frame { 
            border: 1px solid #ddd; 
            border-radius: 5px;
            background: white;
        }
        .device-name {
            font-weight: bold;
            margin-bottom: 10px;
            color: #333;
        }
        .device-specs {
            font-size: 12px;
            color: #666;
            margin-bottom: 10px;
        }
        .error {
            color: red;
            background: #f8d7da;
            padding: 10px;
            border-radius: 3px;
            margin: 10px 0;
        }
        .controls {
            margin-bottom: 15px;
        }
        .btn {
            padding: 8px 15px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 3px;
            cursor: pointer;
            margin-right: 10px;
        }
        .btn:hover {
            background: #0056b3;
        }
    </style>
</head>
<body>
    <div class="toolbar">
        <h1>レスポンシブデザインチェッカー</h1>
        <input type="file" id="fileInput" accept=".html,.htm">
        <div class="controls">
            <button class="btn" onclick="toggleDeviceSelection()">デバイス選択</button>
            <button class="btn" onclick="refreshPreviews()">プレビュー更新</button>
        </div>
        <div id="error" style="display: none;"></div>
    </div>
    <div class="preview-container" id="previewContainer"></div>

    <script>
        const fileInput = document.getElementById('fileInput');
        const previewContainer = document.getElementById('previewContainer');
        const errorDiv = document.getElementById('error');
        
        // デバイスビューポート設定
        const viewports = [
            { 
                name: 'iPhone SE', 
                width: 375, 
                height: 667, 
                category: 'mobile',
                description: 'コンパクトスマートフォン'
            },
            { 
                name: 'iPhone 12 Pro', 
                width: 390, 
                height: 844, 
                category: 'mobile',
                description: '標準スマートフォン'
            },
            { 
                name: 'iPad', 
                width: 768, 
                height: 1024, 
                category: 'tablet',
                description: 'タブレット(縦)'
            },
            { 
                name: 'iPad Pro', 
                width: 1024, 
                height: 768, 
                category: 'tablet',
                description: 'タブレット(横)'
            },
            { 
                name: 'Desktop', 
                width: 1366, 
                height: 768, 
                category: 'desktop',
                description: 'デスクトップ(標準)'
            },
            { 
                name: 'Large Desktop', 
                width: 1920, 
                height: 1080, 
                category: 'desktop',
                description: 'デスクトップ(大画面)'
            }
        ];

        let selectedViewports = [...viewports];
        let currentContent = '';

        // HTMLをエスケープする関数
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }

        // エラー表示関数
        function showError(message) {
            errorDiv.textContent = message;
            errorDiv.style.display = 'block';
        }

        // エラーを隠す関数
        function hideError() {
            errorDiv.style.display = 'none';
        }

        // コンテンツのセキュリティチェック
        function isContentSafe(content) {
            const dangerousPatterns = [
                /<script[^>]*>/i,
                /javascript:/i,
                /data:text\/html/i,
                /on\w+\s*=/i // onclick, onload などのイベントハンドラ
            ];
            
            return !dangerousPatterns.some(pattern => pattern.test(content));
        }

        // プレビュー生成関数
        function generatePreviews(content) {
            hideError();
            
            if (!isContentSafe(content)) {
                showError('セキュリティ上の理由により、スクリプトやイベントハンドラを含むコンテンツは表示できません。');
                return;
            }

            previewContainer.innerHTML = '';

            selectedViewports.forEach(viewport => {
                const previewItem = document.createElement('div');
                previewItem.className = 'preview-item';
                
                const deviceName = document.createElement('div');
                deviceName.className = 'device-name';
                deviceName.textContent = viewport.name;
                
                const deviceSpecs = document.createElement('div');
                deviceSpecs.className = 'device-specs';
                deviceSpecs.textContent = `${viewport.width} × ${viewport.height}px | ${viewport.description}`;
                
                const iframe = document.createElement('iframe');
                iframe.className = 'preview-frame';
                iframe.width = Math.min(viewport.width, 400); // 表示サイズを制限
                iframe.height = Math.min(viewport.height, 300);
                iframe.setAttribute('sandbox', 'allow-same-origin');
                iframe.srcdoc = content;
                
                previewItem.appendChild(deviceName);
                previewItem.appendChild(deviceSpecs);
                previewItem.appendChild(iframe);
                previewContainer.appendChild(previewItem);
            });
        }

        // ファイル読み込みイベント
        fileInput.addEventListener('change', (event) => {
            const file = event.target.files[0];
            
            if (!file) {
                showError('ファイルが選択されていません。');
                return;
            }

            if (!file.name.match(/\.(html|htm)$/i)) {
                showError('HTMLファイルを選択してください。');
                return;
            }

            if (file.size > 5 * 1024 * 1024) { // 5MB制限
                showError('ファイルサイズが大きすぎます(5MB制限)。');
                return;
            }

            const reader = new FileReader();

            reader.onload = (e) => {
                try {
                    currentContent = e.target.result;
                    generatePreviews(currentContent);
                } catch (error) {
                    showError(`エラーが発生しました: ${error.message}`);
                }
            };

            reader.onerror = (e) => {
                showError(`ファイルの読み込み中にエラーが発生しました: ${e.target.error}`);
            };

            reader.readAsText(file);
        });

        // デバイス選択機能
        function toggleDeviceSelection() {
            const modal = document.createElement('div');
            modal.style.cssText = `
                position: fixed;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                background: rgba(0,0,0,0.5);
                z-index: 1000;
                display: flex;
                justify-content: center;
                align-items: center;
            `;
            
            const content = document.createElement('div');
            content.style.cssText = `
                background: white;
                padding: 20px;
                border-radius: 8px;
                max-width: 500px;
                max-height: 70%;
                overflow-y: auto;
            `;
            
            content.innerHTML = `
                <h3>表示するデバイスを選択</h3>
                ${viewports.map((viewport, index) => `
                    <label style="display: block; margin: 10px 0;">
                        <input type="checkbox" ${selectedViewports.includes(viewport) ? 'checked' : ''} data-index="${index}">
                        ${viewport.name} (${viewport.width}×${viewport.height})
                    </label>
                `).join('')}
                <div style="margin-top: 20px;">
                    <button class="btn" onclick="applyDeviceSelection()">適用</button>
                    <button class="btn" onclick="closeModal()" style="background: #6c757d;">キャンセル</button>
                </div>
            `;
            
            modal.appendChild(content);
            document.body.appendChild(modal);
            
            window.currentModal = modal;
        }

        // モーダルを閉じる
        function closeModal() {
            if (window.currentModal) {
                document.body.removeChild(window.currentModal);
                window.currentModal = null;
            }
        }

        // デバイス選択を適用
        function applyDeviceSelection() {
            const checkboxes = window.currentModal.querySelectorAll('input[type="checkbox"]');
            selectedViewports = [];
            
            checkboxes.forEach(checkbox => {
                if (checkbox.checked) {
                    selectedViewports.push(viewports[parseInt(checkbox.dataset.index)]);
                }
            });
            
            if (currentContent) {
                generatePreviews(currentContent);
            }
            
            closeModal();
        }

        // プレビュー更新
        function refreshPreviews() {
            if (currentContent) {
                generatePreviews(currentContent);
            } else {
                showError('プレビューするファイルが選択されていません。');
            }
        }
    </script>
</body>
</html>

このコードでは、選択されたHTMLファイルを複数の画面サイズで同時にセキュアにプレビューします。デバイス選択機能、ファイルサイズ制限、コンテンツのセキュリティチェックが実装されており、開発者はウェブサイトがさまざまなデバイスでどのように表示されるかを安全に確認できます。

○スクリプトテスト

HTMLビューアは、JavaScriptやCSSの動作を安全にテストするためのプラットフォームとしても活用できます。

特に、外部リソースへのアクセスが制限される環境下でのテストに有用です。

ここでは、セキュリティを重視したJavaScriptのコードを安全に実行し、結果を表示できるテスト環境のサンプルコードを紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>セキュアJavaScriptテスト環境</title>
    <style>
        body { 
            display: flex; 
            height: 100vh; 
            margin: 0; 
            font-family: Arial, sans-serif; 
            background: #f5f5f5;
        }
        .panel {
            width: 50%;
            height: 100%;
            display: flex;
            flex-direction: column;
        }
        .panel-header {
            background: #333;
            color: white;
            padding: 15px;
            font-weight: bold;
        }
        #editor { 
            flex: 1;
            font-family: 'Courier New', monospace; 
            font-size: 14px; 
            padding: 15px;
            border: none;
            resize: none;
            outline: none;
            background: #ffffff;
        }
        #output { 
            flex: 1;
            padding: 15px; 
            background: #1e1e1e;
            color: #ffffff;
            overflow-y: auto;
            font-family: 'Courier New', monospace;
            font-size: 12px;
        }
        .toolbar {
            background: #f8f9fa;
            padding: 10px;
            border-bottom: 1px solid #dee2e6;
        }
        .btn {
            padding: 8px 15px;
            margin-right: 10px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 3px;
            cursor: pointer;
            font-size: 12px;
        }
        .btn:hover {
            background: #0056b3;
        }
        .btn.danger {
            background: #dc3545;
        }
        .btn.danger:hover {
            background: #c82333;
        }
        .output-line {
            margin-bottom: 5px;
            word-wrap: break-word;
        }
        .log { color: #ffffff; }
        .error { color: #ff6b6b; }
        .warn { color: #ffd93d; }
        .info { color: #74c0fc; }
        .success { color: #51cf66; }
        .restriction-notice {
            background: #fff3cd;
            color: #856404;
            padding: 10px;
            margin: 10px;
            border-radius: 4px;
            font-size: 12px;
        }
    </style>
</head>
<body>
    <div class="panel">
        <div class="panel-header">コードエディタ</div>
        <div class="toolbar">
            <button class="btn" onclick="runCode()">実行 (Ctrl+Enter)</button>
            <button class="btn" onclick="clearEditor()">クリア</button>
            <button class="btn" onclick="insertSample()">サンプル挿入</button>
        </div>
        <div class="restriction-notice">
            セキュリティ制限: DOM操作、外部リソースアクセス、eval関数は禁止されています
        </div>
        <textarea id="editor" placeholder="JavaScriptコードをここに入力してください...">// サンプルコード
console.log('Hello, World!');

// 配列操作
const numbers = [1, 2, 3, 4, 5];
console.log('配列:', numbers);
console.log('合計:', numbers.reduce((sum, num) => sum + num, 0));

// オブジェクト操作
const person = {
    name: '田中太郎',
    age: 30,
    city: '東京'
};
console.log('人物情報:', person);

// 関数定義と実行
function greet(name) {
    return `こんにちは、${name}さん!`;
}
console.log(greet('世界'));

// エラーハンドリングのテスト
try {
    const result = 10 / 0;
    console.log('計算結果:', result);
} catch (error) {
    console.error('エラーが発生:', error.message);
}</textarea>
    </div>
    
    <div class="panel">
        <div class="panel-header">実行結果</div>
        <div class="toolbar">
            <button class="btn danger" onclick="clearOutput()">出力クリア</button>
            <span style="margin-left: 15px; font-size: 12px;">制限付きサンドボックス環境</span>
        </div>
        <div id="output"></div>
    </div>

    <script>
        const editor = document.getElementById('editor');
        const output = document.getElementById('output');
        
        // 危険な関数やオブジェクトのリスト
        const restrictedItems = [
            'eval', 'Function', 'document', 'window', 
            'localStorage', 'sessionStorage', 'fetch', 'XMLHttpRequest',
            'import', 'require', 'process', 'global', '__dirname', '__filename'
        ];

        // セキュアなログ関数
        function createSecureLogger(type, color) {
            return function(...args) {
                const timestamp = new Date().toLocaleTimeString();
                const logEntry = document.createElement('div');
                logEntry.className = `output-line ${type}`;
                
                const formattedArgs = args.map(arg => {
                    if (typeof arg === 'object' && arg !== null) {
                        try {
                            return JSON.stringify(arg, null, 2);
                        } catch (e) {
                            return String(arg);
                        }
                    }
                    return String(arg);
                });
                
                logEntry.textContent = `[${timestamp}] ${formattedArgs.join(' ')}`;
                output.appendChild(logEntry);
                output.scrollTop = output.scrollHeight;
            };
        }

        // カスタムコンソールオブジェクト
        const customConsole = {
            log: createSecureLogger('log', '#ffffff'),
            error: createSecureLogger('error', '#ff6b6b'),
            warn: createSecureLogger('warn', '#ffd93d'),
            info: createSecureLogger('info', '#74c0fc'),
            clear: () => output.innerHTML = ''
        };

        // コードの安全性チェック
        function isSafeCode(code) {
            // 危険なキーワードのチェック
            for (const item of restrictedItems) {
                if (code.includes(item)) {
                    return { safe: false, reason: `使用禁止項目が含まれています: ${item}` };
                }
            }
            
            // 危険なパターンのチェック
            const dangerousPatterns = [
                /new\s+Function/i,
                /setTimeout\s*\(/i,
                /setInterval\s*\(/i,
                /\.\s*constructor/i,
                /__proto__/i,
                /prototype\s*\[/i
            ];
            
            for (const pattern of dangerousPatterns) {
                if (pattern.test(code)) {
                    return { safe: false, reason: '危険なパターンが検出されました' };
                }
            }
            
            return { safe: true };
        }

        // セキュアなコード実行
        function runCodeSecurely(code) {
            const safetyCheck = isSafeCode(code);
            if (!safetyCheck.safe) {
                customConsole.error(`セキュリティエラー: ${safetyCheck.reason}`);
                return;
            }

            try {
                // 制限された環境でコードを実行
                const func = new Function(
                    'console', 
                    'Math', 
                    'JSON', 
                    'Date', 
                    'Array', 
                    'Object', 
                    'String', 
                    'Number', 
                    'Boolean',
                    `
                    "use strict";
                    ${code}
                    `
                );
                
                func.call(
                    null, 
                    customConsole, 
                    Math, 
                    JSON, 
                    Date, 
                    Array, 
                    Object, 
                    String, 
                    Number, 
                    Boolean
                );
                
            } catch (error) {
                customConsole.error(`実行エラー: ${error.message}`);
            }
        }

        // コード実行関数
        function runCode() {
            const code = editor.value.trim();
            
            if (!code) {
                customConsole.warn('実行するコードがありません');
                return;
            }
            
            customConsole.info('コード実行開始...');
            runCodeSecurely(code);
            customConsole.info('コード実行完了');
        }

        // ツールバー関数
        function clearEditor() {
            editor.value = '';
            editor.focus();
        }

        function clearOutput() {
            output.innerHTML = '';
        }

        function insertSample() {
            const samples = [
                `// 配列メソッドのテスト
const fruits = ['りんご', 'バナナ', 'オレンジ'];
console.log('果物リスト:', fruits);
console.log('最初の果物:', fruits[0]);
console.log('果物の数:', fruits.length);`,

                `// オブジェクト操作
const student = {
    name: '山田花子',
    grade: 3,
    subjects: ['数学', '英語', '理科']
};
console.log('学生情報:', student);
console.log('履修科目:', student.subjects.join(', '));`,

                `// 関数とループ
function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

console.log('フィボナッチ数列:');
for (let i = 0; i < 10; i++) {
    console.log(\`F(\${i}) = \${fibonacci(i)}\`);
}`
            ];
            
            const randomSample = samples[Math.floor(Math.random() * samples.length)];
            editor.value = randomSample;
        }

        // キーボードショートカット
        editor.addEventListener('keydown', (e) => {
            if (e.ctrlKey && e.key === 'Enter') {
                e.preventDefault();
                runCode();
            }
        });

        // 初期メッセージ
        customConsole.info('セキュアJavaScriptテスト環境が準備完了しました');
        customConsole.info('Ctrl+Enterでコードを実行できます');
    </script>
</body>
</html>

このコードでは、左側のテキストエリアにJavaScriptコードを入力すると、右側の Output欄に安全にその実行結果がリアルタイムで表示されます。危険な関数やオブジェクトへのアクセスを制限し、セキュアな環境でコードテストが可能です。

○フォーム確認

HTMLフォームの設計と実装は、多くのウェブアプリケーションにとって重要な要素です。

HTMLビューアを拡張して、フォームの入力検証やサブミット処理を安全にシミュレートする機能を追加することで、開発者はフォームの動作を効率的にテストできます。

これで、ユーザー体験の向上やエラー処理の改善が可能になります。

ここでは、セキュリティを考慮したフォームのプレビューとバリデーションチェックが可能なHTMLビューアのサンプルコードを紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>セキュアフォーム確認ツール</title>
    <style>
        body { 
            font-family: Arial, sans-serif; 
            margin: 0;
            padding: 20px; 
            background: #f5f5f5;
        }
        .container {
            max-width: 1200px;
            margin: 0 auto;
        }
        .panel {
            background: white;
            border-radius: 8px;
            padding: 20px;
            margin-bottom: 20px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        .panel h2 {
            margin-top: 0;
            color: #333;
        }
        #formEditor { 
            width: 100%; 
            min-height: 300px; 
            font-family: 'Courier New', monospace;
            font-size: 14px;
            padding: 15px;
            border: 1px solid #ddd;
            border-radius: 4px;
            resize: vertical;
        }
        #formPreview { 
            min-height: 200px; 
            padding: 20px;
            border: 1px solid #ddd; 
            border-radius: 4px;
            background: #fafafa;
        }
        .btn {
            padding: 10px 20px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            margin-right: 10px;
            margin-bottom: 10px;
        }
        .btn:hover {
            background: #0056b3;
        }
        .btn.success { background: #28a745; }
        .btn.success:hover { background: #218838; }
        .error { 
            color: #dc3545; 
            background: #f8d7da;
            padding: 10px;
            border-radius: 4px;
            margin: 10px 0;
        }
        .success { 
            color: #155724; 
            background: #d4edda;
            padding: 10px;
            border-radius: 4px;
            margin: 10px 0;
        }
        .validation-result {
            margin-top: 15px;
            padding: 15px;
            border-radius: 4px;
            background: #e9ecef;
        }
        .field-error {
            border: 2px solid #dc3545 !important;
            background-color: #f8d7da !important;
        }
        .field-success {
            border: 2px solid #28a745 !important;
            background-color: #d4edda !important;
        }
        .toolbar {
            margin-bottom: 15px;
        }
        .code-templates {
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
            margin-bottom: 15px;
        }
        .security-notice {
            background: #fff3cd;
            color: #856404;
            padding: 10px;
            border-radius: 4px;
            margin-bottom: 15px;
            font-size: 14px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="panel">
            <h1>セキュアフォーム確認ツール</h1>
            <div class="security-notice">
                セキュリティ: スクリプトタグやイベントハンドラを含むコードは自動的に無効化されます
            </div>
        </div>

        <div class="panel">
            <h2>フォームエディタ</h2>
            <div class="code-templates">
                <button class="btn" onclick="loadTemplate('basic')">基本フォーム</button>
                <button class="btn" onclick="loadTemplate('contact')">お問い合わせ</button>
                <button class="btn" onclick="loadTemplate('registration')">会員登録</button>
                <button class="btn" onclick="loadTemplate('survey')">アンケート</button>
            </div>
            <textarea id="formEditor" placeholder="HTMLフォームコードをここに入力してください...">
<form id="sampleForm">
    <fieldset>
        <legend>基本情報</legend>
        
        <label for="name">お名前 (必須):</label>
        <input type="text" id="name" name="name" required maxlength="50">
        <br><br>
        
        <label for="email">メールアドレス (必須):</label>
        <input type="email" id="email" name="email" required>
        <br><br>
        
        <label for="age">年齢:</label>
        <input type="number" id="age" name="age" min="0" max="120">
        <br><br>
        
        <label for="phone">電話番号:</label>
        <input type="tel" id="phone" name="phone" pattern="[0-9\-]+">
        <br><br>
        
        <label for="message">メッセージ:</label>
        <textarea id="message" name="message" rows="4" cols="50" maxlength="500"></textarea>
        <br><br>
        
        <input type="submit" value="送信">
        <input type="reset" value="リセット">
    </fieldset>
</form>
            </textarea>
            <div class="toolbar">
                <button class="btn success" onclick="updatePreview()">プレビュー更新</button>
                <button class="btn" onclick="clearEditor()">クリア</button>
                <button class="btn" onclick="validateHtml()">HTML検証</button>
            </div>
        </div>

        <div class="panel">
            <h2>フォームプレビュー</h2>
            <div id="formPreview"></div>
            <div id="validationResult"></div>
        </div>
    </div>

    <script>
        const formEditor = document.getElementById('formEditor');
        const formPreview = document.getElementById('formPreview');
        const validationResult = document.getElementById('validationResult');

        // フォームテンプレート
        const templates = {
            basic: `<form id="basicForm">
    <label for="name">名前:</label>
    <input type="text" id="name" name="name" required>
    <br><br>
    <label for="email">メール:</label>
    <input type="email" id="email" name="email" required>
    <br><br>
    <input type="submit" value="送信">
</form>`,
            
            contact: `<form id="contactForm">
    <fieldset>
        <legend>お問い合わせフォーム</legend>
        <label for="company">会社名:</label>
        <input type="text" id="company" name="company">
        <br><br>
        <label for="name">お名前 (必須):</label>
        <input type="text" id="name" name="name" required>
        <br><br>
        <label for="email">メールアドレス (必須):</label>
        <input type="email" id="email" name="email" required>
        <br><br>
        <label for="subject">件名:</label>
        <select id="subject" name="subject" required>
            <option value="">選択してください</option>
            <option value="inquiry">一般的なお問い合わせ</option>
            <option value="support">サポート</option>
            <option value="sales">営業・販売</option>
        </select>
        <br><br>
        <label for="message">メッセージ (必須):</label>
        <textarea id="message" name="message" required rows="5" cols="50"></textarea>
        <br><br>
        <input type="submit" value="送信">
    </fieldset>
</form>`,
            
            registration: `<form id="registrationForm">
    <fieldset>
        <legend>会員登録</legend>
        <label for="username">ユーザー名 (必須):</label>
        <input type="text" id="username" name="username" required minlength="3" maxlength="20">
        <br><br>
        <label for="password">パスワード (必須):</label>
        <input type="password" id="password" name="password" required minlength="8">
        <br><br>
        <label for="confirmPassword">パスワード確認 (必須):</label>
        <input type="password" id="confirmPassword" name="confirmPassword" required>
        <br><br>
        <label for="email">メールアドレス (必須):</label>
        <input type="email" id="email" name="email" required>
        <br><br>
        <label for="birthdate">生年月日:</label>
        <input type="date" id="birthdate" name="birthdate">
        <br><br>
        <label>
            <input type="checkbox" name="terms" required>
            利用規約に同意します (必須)
        </label>
        <br><br>
        <input type="submit" value="登録">
    </fieldset>
</form>`,
            
            survey: `<form id="surveyForm">
    <fieldset>
        <legend>満足度アンケート</legend>
        <label for="rating">総合評価 (必須):</label>
        <select id="rating" name="rating" required>
            <option value="">選択してください</option>
            <option value="5">非常に満足</option>
            <option value="4">満足</option>
            <option value="3">普通</option>
            <option value="2">不満</option>
            <option value="1">非常に不満</option>
        </select>
        <br><br>
        <fieldset>
            <legend>利用頻度:</legend>
            <label><input type="radio" name="frequency" value="daily"> 毎日</label><br>
            <label><input type="radio" name="frequency" value="weekly"> 週に数回</label><br>
            <label><input type="radio" name="frequency" value="monthly"> 月に数回</label><br>
            <label><input type="radio" name="frequency" value="rarely"> たまに</label><br>
        </fieldset>
        <br>
        <label for="comments">ご意見・ご要望:</label>
        <textarea id="comments" name="comments" rows="4" cols="50"></textarea>
        <br><br>
        <input type="submit" value="送信">
    </fieldset>
</form>`
        };

        // HTMLをエスケープする関数
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }

        // 危険なコンテンツを除去する関数
        function sanitizeHtml(html) {
            // スクリプトタグを除去
            html = html.replace(/<script[^>]*>.*?<\/script>/gis, '');
            
            // イベントハンドラを除去
            html = html.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '');
            
            // javascript: URLを除去
            html = html.replace(/href\s*=\s*["']javascript:[^"']*["']/gi, '');
            
            return html;
        }

        // フォームバリデーション関数
        function validateForm(form) {
            const results = [];
            let isValid = true;
            const formData = new FormData(form);

            // すべてのフィールドをリセット
            form.querySelectorAll('input, select, textarea').forEach(field => {
                field.classList.remove('field-error', 'field-success');
            });

            // 各フィールドの検証
            form.querySelectorAll('input, select, textarea').forEach(field => {
                const fieldName = field.name || field.id;
                const value = field.value.trim();
                let fieldValid = true;
                let errorMessage = '';

                // 必須フィールドのチェック
                if (field.hasAttribute('required') && value === '') {
                    fieldValid = false;
                    errorMessage = `${fieldName}: 必須項目です`;
                }
                // メールフィールドのチェック
                else if (field.type === 'email' && value && !isValidEmail(value)) {
                    fieldValid = false;
                    errorMessage = `${fieldName}: 有効なメールアドレスではありません`;
                }
                // 数値フィールドのチェック
                else if (field.type === 'number' && value) {
                    const num = Number(value);
                    if (isNaN(num)) {
                        fieldValid = false;
                        errorMessage = `${fieldName}: 有効な数値ではありません`;
                    } else if (field.min && num < Number(field.min)) {
                        fieldValid = false;
                        errorMessage = `${fieldName}: ${field.min}以上の値を入力してください`;
                    } else if (field.max && num > Number(field.max)) {
                        fieldValid = false;
                        errorMessage = `${fieldName}: ${field.max}以下の値を入力してください`;
                    }
                }
                // 最小文字数のチェック
                else if (field.minLength && value.length > 0 && value.length < field.minLength) {
                    fieldValid = false;
                    errorMessage = `${fieldName}: ${field.minLength}文字以上で入力してください`;
                }
                // 最大文字数のチェック
                else if (field.maxLength && value.length > field.maxLength) {
                    fieldValid = false;
                    errorMessage = `${fieldName}: ${field.maxLength}文字以下で入力してください`;
                }
                // 電話番号のパターンチェック
                else if (field.type === 'tel' && value && field.pattern) {
                    const pattern = new RegExp(field.pattern);
                    if (!pattern.test(value)) {
                        fieldValid = false;
                        errorMessage = `${fieldName}: 正しい形式で入力してください`;
                    }
                }

                // パスワード確認のチェック(特別処理)
                if (field.id === 'confirmPassword' || field.name === 'confirmPassword') {
                    const passwordField = form.querySelector('#password, [name="password"]');
                    if (passwordField && value !== passwordField.value) {
                        fieldValid = false;
                        errorMessage = `${fieldName}: パスワードが一致しません`;
                    }
                }

                if (!fieldValid) {
                    field.classList.add('field-error');
                    results.push(errorMessage);
                    isValid = false;
                } else if (value !== '') {
                    field.classList.add('field-success');
                }
            });

            // 成功した場合のデータ表示
            if (isValid) {
                results.push('✓ フォームバリデーションが成功しました');
                results.push('送信されるデータ:');
                for (let [name, value] of formData.entries()) {
                    results.push(`  ${name}: ${value}`);
                }
            }

            return { isValid, results };
        }

        // メールアドレス検証
        function isValidEmail(email) {
            const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
            return emailRegex.test(email);
        }

        // HTML基本構造検証
        function validateHtml() {
            const content = formEditor.value;
            const issues = [];
            
            // フォームタグの存在チェック
            if (!content.includes('<form')) {
                issues.push('フォームタグ(&lt;form&gt;)が見つかりません');
            }
            
            // 基本的なタグバランスチェック
            const openTags = content.match(/<(\w+)[^>]*>/g) || [];
            const closeTags = content.match(/<\/(\w+)>/g) || [];
            
            if (openTags.length !== closeTags.length) {
                issues.push('開始タグと終了タグの数が一致しません');
            }
            
            // 必須属性のチェック
            if (content.includes('required') && !content.includes('type="submit"')) {
                issues.push('必須フィールドがありますが送信ボタンが見つかりません');
            }
            
            if (issues.length === 0) {
                showValidationResult('✓ HTML構造は正常です', 'success');
            } else {
                showValidationResult('HTML検証エラー:\n' + issues.join('\n'), 'error');
            }
        }

        // プレビュー更新関数
        function updatePreview() {
            const content = formEditor.value.trim();
            
            if (!content) {
                formPreview.innerHTML = '<p style="color: #666;">フォームコードを入力してください</p>';
                return;
            }

            try {
                // セキュリティチェックとサニタイズ
                const sanitizedContent = sanitizeHtml(content);
                
                // プレビューに表示
                formPreview.innerHTML = sanitizedContent;
                
                // フォームにイベントリスナーを追加
                const form = formPreview.querySelector('form');
                if (form) {
                    form.addEventListener('submit', function(e) {
                        e.preventDefault();
                        const validation = validateForm(form);
                        
                        if (validation.isValid) {
                            showValidationResult(validation.results.join('\n'), 'success');
                        } else {
                            showValidationResult('バリデーションエラー:\n' + validation.results.join('\n'), 'error');
                        }
                    });
                    
                    form.addEventListener('reset', function(e) {
                        setTimeout(() => {
                            form.querySelectorAll('input, select, textarea').forEach(field => {
                                field.classList.remove('field-error', 'field-success');
                            });
                            validationResult.innerHTML = '';
                        }, 10);
                    });
                }
                
            } catch (error) {
                showValidationResult(`エラーが発生しました: ${error.message}`, 'error');
            }
        }

        // バリデーション結果表示
        function showValidationResult(message, type) {
            validationResult.innerHTML = `<div class="${type}">${escapeHtml(message).replace(/\n/g, '<br>')}</div>`;
        }

        // テンプレート読み込み
        function loadTemplate(templateName) {
            if (templates[templateName]) {
                formEditor.value = templates[templateName];
                updatePreview();
            }
        }

        // エディタクリア
        function clearEditor() {
            formEditor.value = '';
            formPreview.innerHTML = '<p style="color: #666;">フォームコードを入力してください</p>';
            validationResult.innerHTML = '';
        }

        // 初期プレビューを表示
        updatePreview();
    </script>
</body>
</html>

このコードでは、左側のテキストエリアにHTMLフォームのコードを入力し、「プレビュー更新」ボタンをクリックすると、右側にそのフォームが安全にプレビュー表示されます。包括的なバリデーション機能、セキュリティチェック、複数のテンプレート機能が実装されており、開発者はフォームの動作を効率的にテストできます。

○ウェブページアーカイブ

HTMLビューアの機能を拡張して、ウェブページのアーカイブを作成・閲覧するツールを開発することができます。

このツールは、過去のウェブコンテンツを保存し、後で参照したり分析したりする際に非常に有用です。

ここでは、セキュリティを考慮したウェブページアーカイブビューアのサンプルコードを紹介します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>セキュアウェブページアーカイブビューア</title>
    <style>
        body { 
            font-family: Arial, sans-serif; 
            margin: 0;
            padding: 20px; 
            background: #f5f5f5;
        }
        .container {
            max-width: 1400px;
            margin: 0 auto;
            display: flex;
            gap: 20px;
        }
        .sidebar {
            width: 350px;
            background: white;
            border-radius: 8px;
            padding: 20px;
            height: fit-content;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        .main-content {
            flex: 1;
            background: white;
            border-radius: 8px;
            padding: 20px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        .sidebar h2 {
            margin-top: 0;
            color: #333;
            border-bottom: 2px solid #007bff;
            padding-bottom: 10px;
        }
        .archive-item {
            border: 1px solid #e0e0e0;
            border-radius: 5px;
            margin-bottom: 15px;
            padding: 15px;
            cursor: pointer;
            transition: all 0.3s ease;
            background: #fafafa;
        }
        .archive-item:hover {
            border-color: #007bff;
            background: #f0f8ff;
            transform: translateY(-2px);
            box-shadow: 0 4px 8px rgba(0,0,0,0.1);
        }
        .archive-item.active {
            border-color: #007bff;
            background: #e3f2fd;
            box-shadow: 0 4px 8px rgba(0,0,0,0.15);
        }
        .archive-title {
            font-weight: bold;
            color: #333;
            margin-bottom: 8px;
            font-size: 14px;
        }
        .archive-meta {
            font-size: 12px;
            color: #666;
            margin-bottom: 5px;
        }
        .archive-preview {
            font-size: 11px;
            color: #888;
            max-height: 40px;
            overflow: hidden;
            line-height: 1.3;
        }
        .search-box {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            margin-bottom: 20px;
            font-size: 14px;
        }
        .filter-controls {
            margin-bottom: 20px;
        }
        .filter-controls select {
            width: 100%;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            margin-bottom: 10px;
        }
        #viewer {
            width: 100%;
            height: 700px;
            border: 1px solid #ddd;
            border-radius: 4px;
            background: white;
        }
        .viewer-header {
            background: #f8f9fa;
            padding: 15px;
            border-bottom: 1px solid #ddd;
            border-radius: 4px 4px 0 0;
            display: flex;
            justify-content: space-between;
            align-items: center;
            flex-wrap: wrap;
            gap: 15px;
        }
        .page-info {
            flex: 1;
            font-size: 14px;
        }
        .page-title {
            font-weight: bold;
            color: #333;
            margin-bottom: 5px;
        }
        .page-meta {
            color: #666;
            font-size: 12px;
        }
        .viewer-controls {
            display: flex;
            gap: 10px;
        }
        .btn {
            padding: 8px 15px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 3px;
            cursor: pointer;
            font-size: 12px;
        }
        .btn:hover {
            background: #0056b3;
        }
        .btn.secondary {
            background: #6c757d;
        }
        .btn.secondary:hover {
            background: #545b62;
        }
        .error {
            color: #dc3545;
            background: #f8d7da;
            padding: 15px;
            border-radius: 4px;
            margin: 15px 0;
        }
        .no-selection {
            display: flex;
            align-items: center;
            justify-content: center;
            height: 600px;
            color: #666;
            font-size: 18px;
            text-align: center;
        }
        .stats {
            background: #e9ecef;
            padding: 10px;
            border-radius: 4px;
            margin-bottom: 20px;
            font-size: 12px;
        }
        .security-notice {
            background: #fff3cd;
            color: #856404;
            padding: 10px;
            border-radius: 4px;
            margin-bottom: 15px;
            font-size: 12px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="sidebar">
            <h2>アーカイブリスト</h2>
            
            <div class="security-notice">
                セキュリティ: アーカイブされたコンテンツは安全にサンドボックス化されて表示されます
            </div>
            
            <div class="stats" id="stats">
                総アーカイブ数: 0
            </div>
            
            <input type="text" id="searchBox" class="search-box" placeholder="タイトルやURLで検索...">
            
            <div class="filter-controls">
                <select id="categoryFilter">
                    <option value="">すべてのカテゴリ</option>
                    <option value="news">ニュース</option>
                    <option value="blog">ブログ</option>
                    <option value="documentation">ドキュメント</option>
                    <option value="portfolio">ポートフォリオ</option>
                    <option value="ecommerce">Eコマース</option>
                    <option value="other">その他</option>
                </select>
                
                <select id="dateFilter">
                    <option value="">すべての期間</option>
                    <option value="today">今日</option>
                    <option value="week">今週</option>
                    <option value="month">今月</option>
                    <option value="year">今年</option>
                </select>
            </div>
            
            <div id="archiveList"></div>
        </div>
        
        <div class="main-content">
            <div class="viewer-header">
                <div class="page-info">
                    <div class="page-title" id="pageTitle">ページを選択してください</div>
                    <div class="page-meta" id="pageMeta"></div>
                </div>
                <div class="viewer-controls">
                    <button class="btn" onclick="refreshPage()">更新</button>
                    <button class="btn secondary" onclick="exportPage()">エクスポート</button>
                    <button class="btn secondary" onclick="showPageInfo()">詳細情報</button>
                </div>
            </div>
            <iframe id="viewer" sandbox="allow-same-origin"></iframe>
        </div>
    </div>

    <script>
        // サンプルアーカイブデータ(実際にはサーバーやローカルストレージから取得)
        const archives = [
            {
                id: 1,
                title: "サンプルニュースサイト",
                url: "https://example-news.com",
                category: "news",
                archivedDate: new Date('2024-01-15'),
                content: `<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>サンプルニュース</title>
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        .header { background: #003366; color: white; padding: 15px; }
        .article { margin: 20px 0; padding: 15px; border-left: 3px solid #0066cc; }
    </style>
</head>
<body>
    <div class="header">
        <h1>サンプルニュースサイト</h1>
    </div>
    <div class="article">
        <h2>最新ニュース: テクノロジー業界の動向</h2>
        <p>本日、技術業界において重要な発表が行われました。これは業界全体に大きな影響を与えると予想されています。</p>
        <p>詳細については、引き続き報道いたします。</p>
    </div>
</body>
</html>`,
                preview: "最新ニュース: テクノロジー業界の動向について報告..."
            },
            {
                id: 2,
                title: "個人ブログ - Web開発について",
                url: "https://example-blog.com",
                category: "blog",
                archivedDate: new Date('2024-01-20'),
                content: `<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Web開発ブログ</title>
    <style>
        body { font-family: 'Georgia', serif; max-width: 800px; margin: 0 auto; padding: 20px; }
        .post { background: #f9f9f9; padding: 20px; margin: 20px 0; border-radius: 8px; }
        .date { color: #666; font-size: 14px; }
    </style>
</head>
<body>
    <h1>Web開発者のブログ</h1>
    <div class="post">
        <h2>HTMLビューアの重要性について</h2>
        <div class="date">2024年1月20日</div>
        <p>HTMLビューアは現代のWeb開発において不可欠なツールです。適切に設計されたビューアは、開発効率を大幅に向上させます。</p>
        <p>特にセキュリティ面での考慮が重要で、XSS攻撃などから保護する仕組みが必要です。</p>
    </div>
</body>
</html>`,
                preview: "HTMLビューアの重要性について議論し、Web開発における役割を説明..."
            },
            {
                id: 3,
                title: "技術ドキュメント - API仕様書",
                url: "https://api-docs.example.com",
                category: "documentation",
                archivedDate: new Date('2024-01-25'),
                content: `<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>API仕様書</title>
    <style>
        body { font-family: 'Roboto', sans-serif; padding: 20px; background: #f5f5f5; }
        .container { max-width: 1000px; margin: 0 auto; background: white; padding: 30px; }
        .endpoint { background: #e8f5e8; padding: 15px; margin: 15px 0; border-radius: 5px; }
        code { background: #f0f0f0; padding: 2px 5px; border-radius: 3px; }
    </style>
</head>
<body>
    <div class="container">
        <h1>REST API仕様書 v2.0</h1>
        <div class="endpoint">
            <h3>GET /api/users</h3>
            <p>ユーザー一覧を取得します。</p>
            <p><strong>パラメータ:</strong> <code>limit</code>, <code>offset</code></p>
            <p><strong>レスポンス:</strong> JSON形式のユーザーデータ配列</p>
        </div>
        <div class="endpoint">
            <h3>POST /api/users</h3>
            <p>新しいユーザーを作成します。</p>
            <p><strong>リクエストボディ:</strong> ユーザー情報(JSON)</p>
        </div>
    </div>
</body>
</html>`,
                preview: "REST API仕様書 - ユーザー管理APIのエンドポイント仕様..."
            },
            {
                id: 4,
                title: "デザイナーポートフォリオ",
                url: "https://portfolio.example.com",
                category: "portfolio",
                archivedDate: new Date('2024-02-01'),
                content: `<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>デザイナーポートフォリオ</title>
    <style>
        body { margin: 0; font-family: 'Helvetica', sans-serif; }
        .hero { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 60px 20px; text-align: center; }
        .work { display: flex; flex-wrap: wrap; padding: 40px 20px; }
        .work-item { flex: 1; min-width: 300px; margin: 20px; padding: 20px; background: #f8f8f8; border-radius: 8px; }
    </style>
</head>
<body>
    <div class="hero">
        <h1>田中花子 - UI/UXデザイナー</h1>
        <p>美しく機能的なデザインを通じて、ユーザー体験を向上させます</p>
    </div>
    <div class="work">
        <div class="work-item">
            <h3>Webアプリケーション UI</h3>
            <p>モダンで直感的なWebアプリケーションのユーザーインターフェースを設計しました。</p>
        </div>
        <div class="work-item">
            <h3>モバイルアプリ UX</h3>
            <p>ユーザビリティを重視したモバイルアプリの設計を行いました。</p>
        </div>
    </div>
</body>
</html>`,
                preview: "UI/UXデザイナーのポートフォリオサイト - 作品紹介とプロフィール..."
            },
            {
                id: 5,
                title: "オンラインショップ",
                url: "https://shop.example.com",
                category: "ecommerce",
                archivedDate: new Date('2024-02-05'),
                content: `<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>サンプルオンラインショップ</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 0; }
        .header { background: #2c3e50; color: white; padding: 15px 20px; }
        .nav { background: #34495e; padding: 10px 20px; }
        .nav a { color: white; text-decoration: none; margin-right: 20px; }
        .products { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; padding: 20px; }
        .product { border: 1px solid #ddd; padding: 15px; border-radius: 8px; text-align: center; }
        .price { color: #e74c3c; font-size: 18px; font-weight: bold; }
    </style>
</head>
<body>
    <div class="header">
        <h1>サンプルオンラインショップ</h1>
    </div>
    <div class="nav">
        <a href="#home">ホーム</a>
        <a href="#products">商品</a>
        <a href="#about">会社概要</a>
        <a href="#contact">お問い合わせ</a>
    </div>
    <div class="products">
        <div class="product">
            <h3>ワイヤレスヘッドフォン</h3>
            <p>高音質なワイヤレスヘッドフォンです。</p>
            <div class="price">¥12,800</div>
        </div>
        <div class="product">
            <h3>スマートウォッチ</h3>
            <p>健康管理機能付きのスマートウォッチです。</p>
            <div class="price">¥24,800</div>
        </div>
    </div>
</body>
</html>`,
                preview: "オンラインショップ - 電子機器の販売サイト..."
            }
        ];

        let filteredArchives = [...archives];
        let currentPage = null;

        // 初期化
        function init() {
            updateStats();
            renderArchiveList();
            setupEventListeners();
        }

        // 統計情報の更新
        function updateStats() {
            document.getElementById('stats').textContent = `総アーカイブ数: ${filteredArchives.length}`;
        }

        // アーカイブリストの描画
        function renderArchiveList() {
            const archiveList = document.getElementById('archiveList');
            archiveList.innerHTML = '';

            if (filteredArchives.length === 0) {
                archiveList.innerHTML = '<div style="text-align: center; color: #666; padding: 20px;">該当するアーカイブが見つかりません</div>';
                return;
            }

            filteredArchives.forEach(archive => {
                const item = document.createElement('div');
                item.className = 'archive-item';
                item.dataset.id = archive.id;
                
                item.innerHTML = `
                    <div class="archive-title">${escapeHtml(archive.title)}</div>
                    <div class="archive-meta">
                        ${escapeHtml(archive.url)} | ${archive.category} | ${formatDate(archive.archivedDate)}
                    </div>
                    <div class="archive-preview">${escapeHtml(archive.preview)}</div>
                `;
                
                item.addEventListener('click', () => viewPage(archive));
                archiveList.appendChild(item);
            });
        }

        // HTMLエスケープ関数
        function escapeHtml(text) {
            const div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }

        // 日付フォーマット関数
        function formatDate(date) {
            return date.toLocaleDateString('ja-JP', {
                year: 'numeric',
                month: 'short',
                day: 'numeric'
            });
        }

        // ページ表示関数
        function viewPage(archive) {
            try {
                // 以前のアクティブアイテムを解除
                document.querySelectorAll('.archive-item').forEach(item => {
                    item.classList.remove('active');
                });
                
                // 現在のアイテムをアクティブに
                document.querySelector(`[data-id="${archive.id}"]`).classList.add('active');
                
                currentPage = archive;
                
                // ヘッダー情報を更新
                document.getElementById('pageTitle').textContent = archive.title;
                document.getElementById('pageMeta').innerHTML = `
                    <strong>URL:</strong> ${escapeHtml(archive.url)}<br>
                    <strong>アーカイブ日:</strong> ${formatDate(archive.archivedDate)}<br>
                    <strong>カテゴリ:</strong> ${archive.category}
                `;
                
                // セキュリティチェック
                if (isContentSafe(archive.content)) {
                    document.getElementById('viewer').srcdoc = archive.content;
                } else {
                    document.getElementById('viewer').srcdoc = `
                        <div style="padding: 20px; text-align: center; color: #dc3545;">
                            <h2>セキュリティ警告</h2>
                            <p>このアーカイブには安全でないコンテンツが含まれているため表示できません。</p>
                        </div>
                    `;
                }
            } catch (error) {
                showError(`ページの表示中にエラーが発生しました: ${error.message}`);
            }
        }

        // コンテンツ安全性チェック
        function isContentSafe(content) {
            const dangerousPatterns = [
                /<script[^>]*>/i,
                /javascript:/i,
                /data:text\/html/i,
                /on\w+\s*=/i
            ];
            
            return !dangerousPatterns.some(pattern => pattern.test(content));
        }

        // エラー表示
        function showError(message) {
            const viewer = document.getElementById('viewer');
            viewer.srcdoc = `
                <div class="error">
                    <strong>エラー:</strong> ${escapeHtml(message)}
                </div>
            `;
        }

        // フィルタリング関数
        function filterArchives() {
            const searchTerm = document.getElementById('searchBox').value.toLowerCase();
            const categoryFilter = document.getElementById('categoryFilter').value;
            const dateFilter = document.getElementById('dateFilter').value;
            
            filteredArchives = archives.filter(archive => {
                // テキスト検索
                const matchesSearch = !searchTerm || 
                    archive.title.toLowerCase().includes(searchTerm) ||
                    archive.url.toLowerCase().includes(searchTerm);
                
                // カテゴリフィルタ
                const matchesCategory = !categoryFilter || archive.category === categoryFilter;
                
                // 日付フィルタ
                let matchesDate = true;
                if (dateFilter) {
                    const now = new Date();
                    const archiveDate = archive.archivedDate;
                    
                    switch (dateFilter) {
                        case 'today':
                            matchesDate = archiveDate.toDateString() === now.toDateString();
                            break;
                        case 'week':
                            const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
                            matchesDate = archiveDate >= weekAgo;
                            break;
                        case 'month':
                            matchesDate = archiveDate.getMonth() === now.getMonth() && 
                                         archiveDate.getFullYear() === now.getFullYear();
                            break;
                        case 'year':
                            matchesDate = archiveDate.getFullYear() === now.getFullYear();
                            break;
                    }
                }
                
                return matchesSearch && matchesCategory && matchesDate;
            });
            
            updateStats();
            renderArchiveList();
        }

        // イベントリスナーの設定
        function setupEventListeners() {
            document.getElementById('searchBox').addEventListener('input', filterArchives);
            document.getElementById('categoryFilter').addEventListener('change', filterArchives);
            document.getElementById('dateFilter').addEventListener('change', filterArchives);
        }

        // ツールバー関数
        function refreshPage() {
            if (currentPage) {
                viewPage(currentPage);
            }
        }

        function exportPage() {
            if (currentPage) {
                const blob = new Blob([currentPage.content], { type: 'text/html' });
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = `${currentPage.title.replace(/[^a-zA-Z0-9]/g, '_')}.html`;
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
            }
        }

        function showPageInfo() {
            if (currentPage) {
                const info = `
ページ情報:
タイトル: ${currentPage.title}
URL: ${currentPage.url}
カテゴリ: ${currentPage.category}
アーカイブ日: ${formatDate(currentPage.archivedDate)}
コンテンツサイズ: ${Math.round(currentPage.content.length / 1024)}KB
                `;
                alert(info);
            }
        }

        // 初期化実行
        init();
    </script>
</body>
</html>

このツールでは、左側にアーカイブされたページのリストを表示し、右側に選択されたページの内容を安全にiframe内に表示します。検索機能、カテゴリフィルタ、日付フィルタが実装されており、セキュリティチェックも含まれています。このツールを使用することで、過去のウェブコンテンツの保存と参照が容易になり、ウェブサイトの変更履歴を追跡したり、デザインや内容の進化を分析したりすることが可能です。

まとめ

本記事では、セキュリティを重視したHTMLビューアの作り方や使い方、注意点、カスタマイズ方法を初心者向けに詳しく解説しました。

HTMLビューアは、ウェブ開発において欠かせないツールであり、その活用方法を理解することで、開発プロセスを大幅に効率化できます。

特に重要なのは、XSS攻撃やその他のセキュリティリスクから保護するための適切な実装です。innerHTMLの代わりにtextContentを使用する、サンドボックス化されたiframeを活用する、危険なコンテンツを事前にチェックするなど、セキュリティベストプラクティスを常に意識することが必要です。

また、文字コードの自動検出、包括的なエラーハンドリング、ユーザビリティを考慮したインターフェース設計なども、実用的なHTMLビューアを構築する上で重要な要素となります。

この記事で紹介した基本的な概念とセキュアなサンプルコードを基に、読者の皆さんが独自のHTMLビューアを安全に開発し、さらに革新的な機能を追加していけることを願っています。ウェブ開発の効率向上と安全性の確保を両立させた、優れたツールの開発に役立てていただければ幸いです。