リポジトリごとの GitHub Pages でルート相対パスを使うには

GitHub Pages でルート相対パスを使う時の荒業

目次

ルート相対パスとは?

HTML から画像やスタイルシートなどの外部ファイルを指定する時のパスの書き方は、以下の3つがある。

  1. 絶対パス : https://USER-NAME.github.io/SUB-REPO/style.csshttps://USER-NAME.github.io/scripts.js
    • http:// などのプロトコルから表記する。呼び出し元の HTML がどこに配置されようと、目的の外部ファイルを取得できる
  2. 相対パス : style.css (= ./style.css)・../scripts.js
    • 呼び出し元の HTML のパスから見て相対的な位置を ./ (同階層) ないしは ../ (一階層上) で指定する
  3. ルート相対パス (Root Relative Path) : /SUB-REPO/style.css/scripts.js
    • 呼び出し元の HTML から見たルート / を起点としてパスを指定する (ドメイン部分を省略した絶対パスといえる)
    • 呼び方は「ルートパス」「サイトルートパス (Site-Root Relative Path)」などとも

今回対象にするのは、3つ目のルート相対パス。

ローカルで直接 HTML ファイルを開いてしまうと、ルート相対パスの「ルート」が特定できないため上手く開けないが、何らかのサーバ上で動作させれば、http://localhost:8080/ などをルートとみなして解釈できるようになる、というモノだ。

GitHub Pages におけるルート相対パス

GitHub Pages にデプロイした HTML ファイルに関しても、ルート相対パスが使える。

User Site の場合

USER-NAME.github.io の名前で作ったリポジトリで GitHub Pages を公開する場合、ルートは https://USER-NAME.github.io/ とみなされる。

よって、/index.html から、同階層の styles.css を参照したい場合は、以下のように link 要素が書ける。

<link rel="stylesheet" href="/styles.css">

コレは違和感ないだろう。

Project Site の場合

任意のリポジトリで、よく docs/ ディレクトリを作るか gh-pages ブランチを作るかして公開する GitHub Pages。

URL としては https://USER-NAME.github.io/SUB-REPO/ 配下が当該リポジトリの GitHub Pages となるが、この時のルートは https://USER-NAME.github.io/ とみなされてしまう

コレはどういうことかというと、任意のリポジトリ SUB-REPO の直下に index.htmlscripts.js を配置し、index.html から以下のようにルート相対パスで参照させようとした場合、

<script src="/scripts.js"></script>

当該リポジトリ直下から探して、

を参照して欲しいところだが、実際は一階層上の

を探しに行ってしまうのだ。

プロジェクト・サイトでルート相対パスを使いたい場合は、

<script src="/SUB-REPO/scripts.js"></script>

このようにルート相対パスに当該リポジトリ名を含めてやらないといけない、というワケだ。

…お察しかと思うが、この挙動は大変不便だ。

base 要素でも直せない

「ルート」とみなされるパスを変更する方法はないかと思い、base 要素を用意してみた。

SUB-REPO リポジトリ内で、以下のようなバリエーションで base 要素を記述して検証してみた。

<!-- 絶対パスで SUB-REPO リポジトリまで指定・末尾スラッシュなし -->
<base href="https://USER-NAME.github.io/SUB-REPO">
<!-- 絶対パスで SUB-REPO リポジトリまで指定・末尾スラッシュあり -->
<base href="https://USER-NAME.github.io/SUB-REPO/">

<!-- 相対パスで何かを指定してみる・リポジトリ名を含めるのは嫌だけど… -->
<base href=".">
<base href="./">
<base href="SUB-REPO">
<base href="SUB-REPO/">
<base href="./SUB-REPO">
<base href="./SUB-REPO/">

<!-- ルート相対パスで指定してみる・リポジトリ名を含めるのは嫌だけど… -->
<base href="/">
<base href="/SUB-REPO">
<base href="/SUB-REPO/">

また、検証のため、次のようなリンクを用意した。いずれも、https://USER-NAME.github.io/SUB-REPO/test.html に遷移できれば良いな…と思って書いているモノ。

<!-- 相対パス。挙動を確認するため設置 -->
<a href="test.html">
<a href="./test.html">

<!-- ルート相対パス、こう書いて動作させたい… -->
<a href="/test.html">

<!-- ルート相対パス、リポジトリ名を含むのが嫌… -->
<a href="/SUB-REPO/test.html">

結果から行くと、絶対パスで SUB-REPO リポジトリまで記述した場合しか、上手く行かなかった。どれも思った効果は得られなかった。

まず、base 要素にどのようなパスを書いても、ルート相対パスを書いた a 要素に対しては効果がなかった。

base 要素が影響を及ぼすのは相対パス記述のみで、末尾のスラッシュの有無は関係ない。

概念的には、相対パスの記述に対し、手前に base 要素の値を付与している、というモノのようだ。以下の結果表を見れば少しは想像が付くかもしれない。

base 要素の値 + a 要素などの相対パス値 最終的なパス
https://USER-NAME.github.io / ./test.html https://USER-NAME.github.io/test.html
../ (= https://USER-NAME.github.io/) (/) test.html https://USER-NAME.github.io/test.html
https://USER-NAME.github.io/SUB-REPO/ (/) ./test.html https://USER-NAME.github.io/SUB-REPO/test.html
https://USER-NAME.github.io/SUB-REPO/ (/) ../test.html https://USER-NAME.github.io/test.html

base 要素の値に ../ のような相対パスが指定された場合は、その HTML ファイルのパスを起点に、ベースとするフルパスが導かれる。

Node.js を触った人なら、path.join()path.resolve() のように単に結合しているだけ、と思えば分かりやすいか。

で、ルート相対パスが書かれた場合は、base 要素の値は無視して、サイトルート、この場合は https://USER-NAME.github.io/ をルートと見なす、という動きのようだ。

JavaScript でなんとかしてやろう…

それでも、どうしてもプロジェクトサイトでルート相対パスを使いたい、https://USER-NAME.github.io/SUB-REPO/ のようなドメイン以下の任意のパス以下をルートと見なしたい、ということで、JavaScript で制御する方法を編み出した。

今回はたまたま、SUB-REPO 以下の全ての HTML ファイルが /scripts.js を参照する仕様にしてあった。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="/styles.css">
    <script src="/scripts.js"></script>  <!-- ← コレ -->
  </head>
  <body>
    <a href="/test.html"><img src="/image.png"></a>
  </body>
</html>

ルート相対パス /scripts.js は、

と解釈される。プロジェクトサイトではなく、ユーザサイトの領域にある scripts.js を探しに行ってしまうワケだ。

そこで、だ。

ユーザサイトに scripts.js を作成しておき、SUB-REPO の HTML からこの JavaScript ファイルを読み込ませ、そこでルート相対パスを置換することにする。コードの全量は以下。

/** ルート相対パス置換処理 */
(function() {
  /** 定数 : 本ファイルの名前 */
  var thisFileName = 'scripts.js';  // ← ファイル名が異なる場合は変更する
  /** 定数 : 当該 GitHub Pages のルートパス URL を用意する・末尾にスラッシュを付けない */
  var rootPath = 'https://USER-NAME.github.io/SUB-REPO';  // ← ココをリポジトリごとに変更する
  
  // 対象の GitHub Pages から呼び出されていなければ、何も処理せず終了する
  if(location.href.indexOf(rootPath) < 0) {
    return;
  }
  
  /**
   * 指定の要素の属性値をチェックし、ルート相対パス (スラッシュ `/` から始まる値) だった場合、
   * 定数 rootPath を先頭に付与した絶対パスに変換する
   * 
   * @param {string} elementName 要素名
   * @param {string} attributeName 属性名
   */
  var replaceAttribute = function(elementName, attributeName) {
    // console.log(elementName, attributeName, '置換処理開始');
    Array.prototype.forEach.call(document.querySelectorAll(elementName), function(element, index) {
      var attribute = element.getAttribute(attributeName);
      // 属性値がない場合、スラッシュ2つで始まるプロトコル省略の絶対パスの場合、ルート相対パスでない場合は処理しない
      if(!attribute || attribute.substr(0, 2) === '//' || attribute.substr(0, 1) !== '/') {
        return;
      }
      
      if(elementName === 'script' && attribute === thisFileName) {
        // 本ファイル自体は読み込まれているため element は操作しないでおく
        // 代わりに、当該リポジトリ配下にあるはずの同名ファイルを読み込ませるため別要素を作って追加する
        var theScript = document.createElement('script');
        theScript.src = rootPath + attribute;
        element.parentNode.appendChild(theScript);
      }
      else {
        // a 要素・img 要素は属性値変更のみで正しく読み込まれる
        element.setAttribute(attributeName, rootPath + attribute);
        
        // link 要素・script 要素は Node の再挿入を行わないと読込が開始されない
        if(elementName === 'link' || elementName === 'script') {
          var clone = element.cloneNode(true);
          element.parentNode.replaceChild(clone, element);
        }
      }
    });
  };
  
  // 画面のチラつきが発生するため、link 要素のみ本ファイルが読み込まれたタイミングで即処理する
  // (本スクリプトの読み込みは head 要素に書いておくとチラつきが発生しにくくなる)
  replaceAttribute('link', 'href');
  
  /** 初期処理定義 */
  var init = function() {
    replaceAttribute('link', 'href');  // 上の即処理で漏れた CSS ファイルのみ改めて処理する
    replaceAttribute('script', 'src');
    replaceAttribute('a', 'href');
    replaceAttribute('img', 'src');
  };
  
  // 読み込みタイミングに関わらず確実に実行されるよう制御する
  if(!document.readyState || document.readyState === 'interactive') {
    window.addEventListener('load', init);
  }
  else if(document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  }
  else {
    init();
  }
})();

少々長いので順を追って解説する。

こんな感じ。ハッキリ言ってメチャクチャ強引。

自分が必要なかったので、iframeembedobject 要素などには対応させていないが、replaceAttribute() 関数を拡張すれば対応できると思う。

以上

今回のスクリプトは、自分のメインサイト Neo's World のミラーサイトを GitHub Pages で公開するにあたって編み出した。

という話でした。