手元のHTMLをまとめて閲覧する仕組みを作り始めたとき、僕は「ダブルクリックで開ければ十分」と考えていました。ブラウザさえあれば使えて、サーバを起動する手間もありません。
その前提が崩れたのは、ファイルが増えてフォルダを組み替えたときでした。ページの場所を基準にしたリンクが、まとめて切れたのです。端末固有の絶対パスを使うと、今度は別のPCで開けません。
そこで、file:// で直接開く方式をやめ、127.0.0.1 にだけ公開する小さなHTTPサーバを使うことにしました。この記事では、何が解決し、何は解決しないのかまで分けて書きます。2026年7月に、Windows 11とWindows PowerShell 5.1で確認しました。
まず全体像
file:// と相対参照は、参照元を動かすと切れる
最初はHTMLをフォルダに並べ、目次をダブルクリックで開いていました。リンクは同じ階層なら ./next.html、ひとつ上なら ../other/index.html と書きます。
この書き方は、参照元のページがどこにあるかを基準に解決されます。そのため、分類を作り直してページを別の深さへ動かすと、../ の数も直さなければなりません。
HTMLにリンクの書き方がないわけではありません。問題は、素のHTMLには、ファイル名だけで参照して移動後も自動追随する仕組みがないことです。href に書いたURL参照は、基準URLとの規則に従って解決されます。
ファイルをほとんど動かさない小規模な資料なら、この方式がいちばん手軽です。僕の用途では分類を今後も直す予定があり、参照元を動かすたびにリンクを点検する運用が合いませんでした。
端末固有の絶対パスは、別のPCで切れる
次に考えたのは、file:///C:/Users/.../index.html のような絶対パスです。参照元をどこへ動かしても、参照先のファイルが同じ場所にある限りURLは変わりません。
しかし、ファイル一式は同期フォルダに置いていました。同期先のドライブやユーザーフォルダは、PCごとに同じとは限りません。あるPCで書いた絶対パスは、別のPCではそのまま使えないことがあります。
相対参照は参照元の移動に弱く、端末固有の絶対パスは環境の違いに弱い。解決したい問題が2つあるため、どちらか一方を選ぶだけでは足りませんでした。
HTTP配信にすると、ルート相対参照が使える
そこで使ったのが、/common/style.css のように / から始めるルート相対参照です。HTTPで開いていれば、これはサイトの配信ルートを基準に解決されます。参照元のページが何階層目に移っても、配信ルートから見た参照先が同じなら書き換える必要はありません。
PC上の保存場所が変わっても、ブラウザから見えるURLは http://127.0.0.1:ポート/ のままです。ファイルシステム上の場所と、HTMLから見える基準URLを分けられます。
同じ /common/style.css を file:// のページで使うと、サイトのルートではなく、そのファイルURLのボリューム直下を基準に解決されます。今回必要なURLの基準とは違うため、HTTPで配信することにしました。
ただし、参照先を /common/ から /assets/ へ移せばリンクの更新は必要です。この方式で解決できるのは、参照元の深さとPC上の保存場所の違いです。
PowerShellで、ループバックだけに配信する
今回は、新しいランタイムを増やさないことも条件でした。WindowsにあるPowerShellから、接続を受け付ける.NETの機能 TcpListener を使い、静的ファイルだけを返す小さなサーバを用意しました。
下のコードは、手元のサーバをそのまま転載したものではありません。公開用に、配信で外せない点だけを整理した抜粋です。
自分のPCからの接続だけを受け付ける
$listener = New-Object System.Net.Sockets.TcpListener(
[System.Net.IPAddress]::Loopback,
$Port
)
IPAddress.Loopback は、自分のPCだけを指す接続先です。IPv4では 127.0.0.1 になります。待ち受け先をここに限定すると、同じPCからの接続だけを受け付けます。
これは、配信しているHTMLが外部サイトへ通信しないことまで保証するものではありません。HTML内に外部URLがあれば、ブラウザは別途そこへアクセスできます。
配信ルートの外を読ませない
$root = [System.IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\'
$candidate = [System.IO.Path]::GetFullPath((Join-Path $root $relativePath))
$insideRoot = $candidate.StartsWith(
$root,
[System.StringComparison]::OrdinalIgnoreCase
)
要求されたパスを正規化したあと、配信ルート内のファイルか確認します。ルート末尾の区切り文字を含めて比較しないと、C:\work\site と C:\work\site-private のような隣接フォルダを区別できません。
実際のスクリプトでは、このほかに要求の形式確認、URLに含まれる文字の変換、ファイルがない場合の 404 Not Found、接続のクローズを入れています。応答には、データの種類を示す Content-Type と、長さを示す Content-Length も付けます。
CSSやJavaScript、JSONは、それぞれの種類に合う Content-Type で返す必要があります。
このサーバは、自分のPCで、内容を把握しているHTMLを確認する用途に限っています。同時接続をさばく構成でも、LANやインターネットへ公開するサーバでもありません。
また、配信ルート内にジャンクションやシンボリックリンクを置かない前提です。一般公開するなら、実績のあるWebサーバを使うべきです。
fetch() でJSONを読めるようになった
HTTP配信にしたことで、JavaScriptから同じ配信元のJSONを読み込む構成も使えるようになりました。
<p id="out">checking...</p>
<script>
fetch('/data/data.json')
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function (data) {
document.getElementById('out').textContent = data.message;
})
.catch(function (error) {
document.getElementById('out').textContent = error.message;
});
</script>
file:// のローカルファイルは、同じフォルダにあっても、ブラウザから同じ配信元とみなされないことがあります。そのため、別の配信元からの読み込みを制限するCORSによって、fetch() が失敗する場合があります。
たとえば、HTMLを http://127.0.0.1:8899/、JSONを http://127.0.0.1:8899/data/data.json から読むようにすれば、ブラウザから同じ配信元として扱われます。
手軽さより、整理を続けられることを取った
この方式では、HTMLを開く前にバッチファイルを起動します。サーバのウィンドウも、閲覧中は開いたままです。ダブルクリックでHTMLを直接開くより、手順はひとつ増えました。
それでも切り替えたのは、ファイルが増えたあとも分類を直せることを優先したからです。参照元の深さとPC上の保存場所をURLの基準から外せる効果は、起動のひと手間より大きいと判断しました。
個々のHTMLが外部ファイルに依存しなければ、今でも file:// で単体表示できます。将来このPowerShellサーバをやめても、同じ配信ルートを別の静的サーバで公開すれば、ルート相対参照はそのまま使えます。
まとめ
ローカルHTMLの開き方は、リンクの基準をどこに置くかで決まります。
- ファイルを動かさず、手軽さを優先するなら、
file://と相対参照で足ります。 - 1台だけで使い、参照先の保存場所を固定できるなら、端末固有の絶対パスも使えます。
- 参照元の階層を変え、複数のPCでも同じ構成を使うなら、HTTP配信とルート相対参照が扱いやすくなります。
僕の場合は、分類を組み替えながら育てる前提だったため、3つ目を選びました。ただし、HTTPへ変えればすべてのリンク切れが消えるわけではありません。何を安定させたいのかを先に分けたことで、選択基準がはっきりしました。