Hugo ブログ + GitHub Pages 完全デプロイガイド

★環境準備

  • デバイス:MacBook(macOS)
  • ツール
    • Git
    • Hugo
    • GitHub アカウント

Hugo のインストール

brew install hugo

インストールの確認:

hugo version

★Hugo ブログの作成

mkdir blog && cd blog
hugo new site .

Git の初期化:

git init

★テーマの選択 & 追加

PaperMod テーマを推奨:

git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod

config.toml を編集し、以下を追加:

theme = "PaperMod"
baseURL = "https://yuuniji.github.io"

★ブログ記事の作成

最初の記事を作成:

hugo new posts/hello-world.md

content/posts/hello-world.md を編集:

---
title: "Hello World"
date: 2025-02-22T12:00:00
draft: false
---

これは私の最初のブログ記事です!

★ローカルプレビュー(初回デプロイ時はスキップ)

hugo server -D

ブラウザでアクセス:

http://localhost:1313

★GitHub への公開

1. GitHub リポジトリの作成

  • blog(Hugo ソースコード用)
  • yuuniji.github.io(生成された静的ファイル用)

2. blog リポジトリのプッシュ

git remote add origin https://github.com/yuuniji/blog.git
git branch -M main
git add .
git commit -m "Initial commit"
git push -u origin main

3. 静的ファイルの生成

hugo -D

静的ファイルは public/ ディレクトリにあります。

4. yuuniji.github.io へのプッシュ

cd public
git init
git remote add origin https://github.com/yuuniji/yuuniji.github.io.git
git checkout -b main
git add .
git commit -m "Deploy Hugo site"
git push -f origin main

★GitHub Pages の設定

  • yuuniji.github.io リポジトリの Settings → Pages に移動。
  • main ブランチを選択し、保存後デプロイ完了を待つ。
  • https://yuuniji.github.io にアクセスしてブログを確認。

★自動デプロイ(オプション)

1. blog リポジトリに deploy.sh を追加

nano deploy.sh

以下を貼り付け:

#!/bin/bash
hugo -D
cd public
git add .
git commit -m "Deploy: $(date)"
git push origin main
cd ..

保存して終了(Ctrl + XY、Enter)。

2. 実行権限を付与

chmod +x deploy.sh

3. デプロイスクリプトの実行

./deploy.sh

★完成!

以降のブログ更新は、以下の手順で行います:

  1. blog/ ディレクトリで記事を作成 hugo new posts/xxx.md
  2. ./deploy.sh を実行
  3. https://yuuniji.github.io にアクセスして更新を確認

💡 問題があればこのノートを参照してください! 🚀

★Hugo ブログの多言語対応 & 機能拡張ガイド

クイックリンク

baseURL = "https://yuuniji.github.io"
title = "Yuuniji的博客"
theme = "PaperMod"
defaultContentLanguage = "zh"
hasCJKLanguage = true

[languages]
  [languages.zh]
    languageName = "中文"
    weight = 1
    contentDir = "content/zh"
    [languages.zh.params]
      archivesTitle = "归档"
      archivesDescription = "按年份和月份查看文章归档"
    [languages.zh.menu]
      [[languages.zh.menu.main]]
        name = "首页"
        url = "/"
        weight = 1
      [[languages.zh.menu.main]]
        name = "关于"
        url = "/about/"
        weight = 2
      [[languages.zh.menu.main]]
        name = "标签"
        url = "/tags/"
        weight = 3
      [[languages.zh.menu.main]]
        name = "归档"
        url = "/archives/"
        weight = 4
      [[languages.zh.menu.main]]
        name = "搜索"
        url = "/search/"
        weight = 5

  [languages.en]
    languageName = "English"
    weight = 2
    contentDir = "content/en"
    [languages.en.params]
      archivesTitle = "Archives"
      archivesDescription = "Browse articles by year and month"
    [languages.en.menu]
      [[languages.en.menu.main]]
        name = "Home"
        url = "/"
        weight = 1
      [[languages.en.menu.main]]
        name = "About"
        url = "/about/"
        weight = 2
      [[languages.en.menu.main]]
        name = "Tags"
        url = "/tags/"
        weight = 3
      [[languages.en.menu.main]]
        name = "Archives"
        url = "/archives/"
        weight = 4
      [[languages.en.menu.main]]
        name = "Search"
        url = "/search/"
        weight = 5

  [languages.ja]
    languageName = "日本語"
    weight = 3
    contentDir = "content/ja"
    [languages.ja.params]
      archivesTitle = "アーカイブ"
      archivesDescription = "年と月ごとに記事を表示"
    [languages.ja.menu]
      [[languages.ja.menu.main]]
        name = "ホーム"
        url = "/"
        weight = 1
      [[languages.ja.menu.main]]
        name = "について"
        url = "/about/"
        weight = 2
      [[languages.ja.menu.main]]
        name = "タグ"
        url = "/tags/"
        weight = 3
      [[languages.ja.menu.main]]
        name = "アーカイブ"
        url = "/archives/"
        weight = 4
      [[languages.ja.menu.main]]
        name = "検索"
        url = "/search/"
        weight = 5

[outputs]
  home = ["HTML", "RSS", "JSON", "SITEMAP"]
  section = ["HTML", "RSS"]
  archives = ["HTML"]
  search = ["HTML"]

[params]
  author = "Yuuniji"
  defaultTheme = "auto"
  ShowAllPagesInArchive = true
  ShowShareButtons = true
  ShowReadingTime = true
  ShowToc = true
  TocOpen = false
  ShowBreadCrumbs = true
  ShowCodeCopyButtons = true
  ShowPostNavLinks = true
  ShowRssButtonInSectionTermList = true
  archives = true
  [params.fuseOpts]
    isCaseSensitive = false
    shouldSort = true
    location = 0
    distance = 1_000
    threshold = 0.4
    minMatchCharLength = 0
    keys = [ "title", "permalink", "summary", "content" ]


[content]
  archivesDir = "content/archives"

1. ディレクトリ構造

hugo.toml の設定に従い、content ディレクトリの構造は以下のようになります:

content/
  ├── zh/
  │   ├── _index.md
  │   ├── about.md
  │   ├── archives.md
  │   ├── search.md
  │   ├── categories/_index.md
  │   ├── posts/_index.md
  │   ├── tags/_index.md
  ├── en/
  │   ├── _index.md
  │   ├── about.md
  │   ├── archives.md
  │   ├── search.md
  │   ├── categories/_index.md
  │   ├── posts/_index.md
  │   ├── tags/_index.md
  ├── ja/
      ├── _index.md
      ├── about.md
      ├── archives.md
      ├── search.md
      ├── categories/_index.md
      ├── posts/_index.md
      ├── tags/_index.md

また、archetypes ディレクトリを作成し、各言語に対応させます:

archetypes/
  ├── default.md
  ├── zh.md
  ├── en.md
  ├── ja.md

2. archetypes ディレクトリの設定

各言語の archetypes ファイルは、デフォルトの front matter を設定するために使用されます。

デフォルト archetypes/default.md

---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
tags: []
categories: []
---

archetypes/zh.md(中文)

---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
tags: []
categories: []
lang: "zh"
---

archetypes/en.md(英文)

---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
tags: []
categories: []
lang: "en"
---

archetypes/ja.md(日本語)

---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
tags: []
categories: []
lang: "ja"
---

3. 各言語の記事作成

--kind オプションを使用して archetypes を指定し、対応する content/ ディレクトリに記事を作成します。

中文記事

hugo new content/zh/posts/my-first-post1.md

これにより、content/zh/posts/ ディレクトリに my-first-post.md が作成され、archetypes/zh.md がテンプレートとして使用されます。

英文記事

hugo new content/en/posts/my-first-post1.md

これにより、content/en/posts/ ディレクトリに my-first-post.md が作成され、archetypes/en.md がテンプレートとして使用されます。

日本語記事

hugo new content/ja/posts/my-first-post1.md

これにより、content/ja/posts/ ディレクトリに my-first-post.md が作成され、archetypes/ja.md がテンプレートとして使用されます。

自動化による多言語記事の作成

create_multilingual_post.sh スクリプト

#!/bin/bash

# 入力パラメータが存在するか確認
if [ -z "$1" ]; then
    echo "記事のタイトルを入力してください。"
    exit 1
fi

TITLE="$1"
DATE=$(date +%Y-%m-%d)

# 記事のファイル名を作成(単語をハイフンで区切る)
POST_NAME=$(echo "$TITLE" | tr " " "-")

# 中国語の記事を生成
mkdir -p "content/zh/posts"
cat > "content/zh/posts/$POST_NAME.md" <<EOL
---
title: "$TITLE"
date: $DATE
lang: "zh"
draft: true
tags: []
categories: []
---
ここは中国語バージョンの内容です。
EOL

# 英語の記事を生成
mkdir -p "content/en/posts"
cat > "content/en/posts/$POST_NAME.md" <<EOL
---
title: "$TITLE"
date: $DATE
lang: "en"
draft: true
tags: []
categories: []
---
This is the English version of the post.
EOL

# 日本語の記事を生成
mkdir -p "content/ja/posts"
cat > "content/ja/posts/$POST_NAME.md" <<EOL
---
title: "$TITLE"
date: $DATE
lang: "ja"
draft: true
tags: []
categories: []
---
この記事は日本語バージョンです。
EOL

echo "3つの言語バージョンの記事が正常に作成されました:$TITLE"

ターミナルで、まずスクリプトが存在するディレクトリに移動し、実行権限を付与します:

chmod +x create_multilingual_post.sh

以下のコマンドを実行し、記事のタイトルを指定します:

./create_multilingual_post.sh "あなたの記事タイトル"

まとめ

  • 各言語の記事を作成する には、hugo new --kind <language> <path> を使用します。
  • archetypes ディレクトリ には、zh.mden.mdja.md を用意し、content/ ディレクトリと対応させます。
  • contentDir の構造を一貫させる ことで、Hugo が記事を生成する際に正しいテンプレートを見つけられるようにします。

これで、Hugo の多言語ブログの記事作成を正しく管理できるようになります!🚀

★アップグレード

◇サイドバー目次の追加

PaperModテーマはデフォルトでサイドバー目次をサポートしていませんが、arashsm79/hugo-PaperMod-Mod リポジトリの sidetoc.css ファイルを利用することでサイドバー目次を実装できます。手順は以下の通りです:

  1. sidetoc.css ファイルをダウンロードします:sidetoc.css
  2. ファイルをHugoサイトの assets/css/extended/ ディレクトリに配置します。ディレクトリが存在しない場合は作成してください。
  3. サイトを再構築(hugo コマンドを実行)すると、記事のサイドバーに目次が表示されます。

注意事項

  • 記事に十分な見出し(例:# H1## H2 など)が含まれていない場合、目次は生成されません。
  • sidetoc.css はCSS Gridと position: sticky を使用してサイドバーの固定効果を実現しており、大画面表示に適しています。
  • 外観や位置に満足できない場合、sidetoc.css ファイルをカスタマイズできます。

長い目次がスクロールできない問題の解決

  • 調査によると、目次が長すぎる場合は、最大高さを設定し、スクロール可能なオーバーフローを有効にすることで解決できます。
  • カスタムCSSに max-heightoverflow-y: auto を追加することを推奨します。
  • PaperMod公式では長い目次のスクロールを直接サポートしていないため、コミュニティの修正に依存する必要があります。

実装手順

長い目次をスクロール可能にするための詳細な手順は以下の通りです:

  1. 目次のHTML構造を確認

    • ブラウザで記事ページを開き、目次エリアを右クリックして「検証」(Inspect)または「要素を調査」を選択します。
    • 目次を含むHTML要素を見つけます。通常は <nav id="TableOfContents"> です。Hugoのデフォルト目次ジェネレーターはこのIDを生成します。
    • 他のラッパー要素(例:<div class="toc"> など)が存在するかどうかを確認します。これらは sidetoc.css によって追加されている可能性があります。存在する場合は、クラス名またはIDを記録して後で調整します。
  2. カスタムCSSの追加

    • PaperModテーマは assets/css/extended/ ディレクトリを通じてカスタムCSSを追加できます。ユーザーはこのディレクトリ内にファイル(例:custom.css)を作成または編集できます。
    • custom.css に以下のコードを追加します:
      #TableOfContents {
        max-height: 80vh; /* 最大高さをビューポートの高さの80%に設定。必要に応じて調整可能 */
        overflow-y: auto; /* 垂直方向のオーバーフロー時にスクロールバーを表示 */
      }
      
    • 目次が他のコンテナ(例:<div class="toc">)でラップされている場合、セレクタを以下のように調整します:
      .toc {
        max-height: 80vh;
        overflow-y: auto;
      }
      
    • max-height: 80vh は推奨値です。サイドバーのレイアウトに応じて、たとえば 70vh500px に調整し、さまざまな画面サイズに対応できます。

◇名言・格言

quoteszh.htmlquotesen.htmlquotesja.html ファイルを作成し、Hugoサイトの layouts/shortcodes/ ディレクトリに配置します。その後、content/lang/_index.md で参照してください。

<!-- layouts/shortcodes/quoteszh.html -->
<blockquote>
    <p>{{ .Inner }}</p>
    <footer>{{ .Get "source" }}</footer>
</blockquote>

<div id="quote" class="quote-box"></div>
<script>
    const quotes = [
        "",
        "",
        ""
    ]
    // ランダムに名言を選択
    const randomQuote = quotes[Math.floor(Math.random() * quotes.length)];

    // 名言を表示
    document.getElementById('quote').innerHTML = randomQuote;
</script>

<style>
    .quote-box {
        max-width: 600px;
        font-size: 0.8rem;
        text-align: left;
    }

    .quote-box {
        animation: fadeIn 1.5s ease-in-out;
    }

    @keyframes fadeIn {
        0% {
            opacity: 0;
        }

        100% {
            opacity: 1;
        }
    }
</style>

★その他

◇コードの枠線を角丸にする

/assets/css/common/post-single.css を以下のように修正します:

.post-content .highlight pre {
  background-color: var(--theme) !important;
  margin: 0;
  border-radius: 8px; /* 角丸を追加 */
}

◇多言語(中国語、英語、日本語)対応のウェブサイト稼働時間表示

/layouts/partials/footer.html<footer> 内に以下のコードを追加します:

<span id="runtime_span"></span> <!-- ウェブサイト稼働時間を表示するコンテナ -->
<script type="text/javascript">
function show_runtime() {
    // 1秒ごとに実行し、リアルタイム更新を実現
    setTimeout(show_runtime, 1000);

    // ウェブサイトの開始日時を設定(2025年2月22日 00:00:00)
    const startDate = new Date("2025/02/22 00:00:00");

    // 現在の日時を取得
    const now = new Date();

    // 時間差を計算(ミリ秒)
    const diff = now.getTime() - startDate.getTime();

    // 稼働日数、時間、分、秒を計算
    const days = Math.floor(diff / (24 * 60 * 60 * 1000));                     // 日数
    const hours = Math.floor((diff % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)); // 時間
    const minutes = Math.floor((diff % (60 * 60 * 1000)) / (60 * 1000));        // 分
    const seconds = Math.floor((diff % (60 * 1000)) / 1000);                    // 秒

    // HTMLページの言語設定を取得(Hugoが生成、例:<html lang="ja">)
    const lang = document.documentElement.lang || "en"; // デフォルトは英語

    // 各言語の表示テンプレート
    const translations = {
        zh: `网站已运行 ${days}${hours} 小时 ${minutes}${seconds} 秒`,
        ja: `サイトは稼働してから ${days}${hours}時間 ${minutes}${seconds}秒`,
        en: `Site has been running for ${days} days ${hours} hours ${minutes} minutes ${seconds} seconds`
    };

    // 現在の言語に基づいて表示内容を選択、存在しない場合は英語を使用
    const output = translations[lang] || translations["en"];

    // 表示内容をページに書き込む
    document.getElementById("runtime_span").innerHTML = output;
}

// 初期化実行
show_runtime();
</script>

◇コメントシステム

以下は、GitHub Pages やその他の静的ホスティングプラットフォームにデプロイされた Hugo ブログに Giscus コメントシステム を詳細に統合する手順です。


1:GitHub リポジトリの準備

  1. GitHub アカウントにログインします。
  2. ブログのリポジトリ(例:yourname/yourblog)を開きます。
  3. Discussions 機能を有効化します:
    • リポジトリの設定 → FeaturesDiscussions にチェックを入れます。
    • Discussions ページに移動し、新しい Discussion Category(例:General)を作成します。

2:Giscus の埋め込みコード生成

  1. Giscus の設定ページを開きます: 👉 https://giscus.app

  2. 以下の設定を行います:

    • Repository:ブログのリポジトリ(例:yourname/yourblog
    • Repository ID / Category / Category ID:Discussions の情報に基づいて自動生成
    • Discussion Mappingpathname を推奨
    • Reaction:有効化(👍など)
    • Input Positionbottom(コメント入力欄を下部に)
    • Themepreferred_color_scheme(ライト/ダークモードに自動対応)
    • Languagezh-CN(中国語)、ja(日本語)、または en(英語)を選択
  3. 生成された <script> コードをコピーします。


3:Hugo テンプレートに Giscus コードを挿入

Giscus のコードを独立したファイルに保存します。例:layouts/partials/giscus.html

<div id="giscus_container"></div>
<script src="https://giscus.app/client.js"
        data-repo="yourname/yourblog"
        data-repo-id="リポジトリID"
        data-category="General"
        data-category-id="カテゴリID"
        data-mapping="pathname"
        data-strict="0"
        data-reactions-enabled="1"
        data-emit-metadata="0"
        data-input-position="bottom"
        data-theme="preferred_color_scheme"
        data-lang="ja"
        crossorigin="anonymous"
        async>
</script>

次に、single.html テンプレートで呼び出します:

<article>
  {{ .Content }}
</article>

{{ partial "giscus.html" . }}

4:Hugo ブログの再構築とデプロイ

以下のコマンドを実行:

hugo

◇Note のサイドノート機能

1:使用例

これは本文中の文です。強調語本文を続けます。

これは本文中の文です。<span class="note" data-note="これはサイドノートの内容です。">強調語</span>本文を続けます。

2:CSS スタイルの追加

テーマまたはサイトの CSS ファイルに以下のスタイルを追加します(例:assets/css/extended/custom.css):

.note {
  color: #4CAF50;
  font-weight: bold;
  border-bottom: 1px dotted #4CAF50;
  cursor: help;
  position: relative;
}

.note:hover::after {
  content: attr(data-note);
  position: absolute;
  bottom: 100%;
  left: 0;
  background-color: #4CAF50;
  color: white;
  padding: 5px;
  border-radius: 4px;
  font-size: 12px;
  white-space: nowrap;
  z-index: 1;
}

◇MarginNote のサイドノート機能

1:ショートコードの定義

Hugo プロジェクト内にショートコードファイルを作成します:

パス:layouts/shortcodes/marginnote.html

内容:

<span class="marginnote" data-note="{{ .Get "note" }}">{{ .Inner }}</span>

使用例:

これは本文中の文です。強調語 本文を続けます。


2:CSS スタイルの追加

テーマまたはサイトの CSS ファイルに以下のスタイルを追加します(例:assets/css/extended/custom.css):

.marginnote {
  position: relative;
  cursor: pointer;
  color: #007acc;
  border-bottom: 1px dotted #007acc;
}

.marginnote::after {
  content: attr(data-note);
  display: none;
  position: absolute;
  top: 1.5em;
  left: 100%;
  width: 220px;
  padding: 8px 12px;
  margin-left: 10px;
  background: #f9f9f9;
  border: 1px solid #ccc;
  font-size: 0.9em;
  z-index: 999;
  white-space: normal;
  box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}

.marginnote:hover::after {
  display: block;
}

効果の説明

  • マウスを本文のキーワードにホバーすると、サイドノートが右側にポップアップ表示され、読者の閲覧フローを妨げません。
  • Markdown の記事形式に影響を与えず、ショートコードは使いやすいです。

◇画像クリック拡大機能

多くの方法がありますが、ここでは Medium Zoom(推奨)を使用します。

1:部分テンプレートファイルの作成

Hugo プロジェクト内にファイルを作成:layouts/partials/image-zoom.html

<!-- layouts/partials/image-zoom.html -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/medium-zoom/1.0.6/medium-zoom.min.js"></script>
<script>
  document.addEventListener('DOMContentLoaded', function() {
    // 記事内のすべての画像にクリック拡大機能を追加
    mediumZoom('article img', {
      margin: 24,
      background: 'rgba(0, 0, 0, 0.8)',
      scrollOffset: 40
    });
  });
</script>

<!-- 画像がクリック可能に見えるようにCSSスタイルを追加 -->
<style>
  article img {
    cursor: zoom-in;
    transition: transform 0.2s ease-in-out;
  }
  article img:hover {
    transform: scale(1.02);
  }
</style>

2:レイアウトテンプレートに部分テンプレートを含める

主レイアウトテンプレート(通常は layouts/_default/baseof.html または layouts/_default/single.html)の </body> タグ直前に以下を追加:

{{ partial "image-zoom.html" . }}

★HugoブログにInstantClickを統合してリフレッシュなしのジャンプを実現

◇目的

ブログ内の記事ジャンプ体験を向上させ、「リフレッシュなしの瞬時オープン」を実現し、ページのちらつきを軽減する。

◇手順と変更の説明

  1. InstantClickスクリプトの導入

    • themes/PaperMod/layouts/partials/footer.html ファイル内の {{- partial "extend_footer.html" . }} の後に、以下のコードを挿入:
      <!-- InstantClick 3.1.0 CDNスクリプト統合 -->
      <script src="https://cdn.jsdelivr.net/npm/instantclick@3.1.0/instantclick.min.js" data-no-instant></script>
      <script data-no-instant>
      // 統計スクリプトの適応例(実際の状況に応じて調整可能)
      document.addEventListener('instantclick:change', function() {
          // Google Analytics
          if (typeof ga === 'function') {
              ga('set', 'page', location.pathname + location.search);
              ga('send', 'pageview');
          }
          // 百度統計(_hmtがロード済みと仮定)
          if (typeof _hmt === 'object' && _hmt.push) {
              _hmt.push(['_trackPageview', location.pathname + location.search]);
          }
          // ここに他の統計や初期化コードを追加可能
      });
      InstantClick.init();
      </script>
      
    • これにより、ブログ内のすべてのページジャンプがリフレッシュなしになり、よりスムーズな体験が得られる。
  2. 統計スクリプトの適応

    • Google Analyticsや百度統計などの一般的な統計に対応し、instantclick:change イベントリスナーを追加することで、ジャンプ後の統計データが正常に記録される。
  3. いつでも削除可能

    • 元の状態に戻したい場合は、上記のコードブロックを削除するだけでよい。

◇注意事項

  • 他のカスタムJSやサードパーティスクリプトがある場合も、instantclick:change イベント内で初期化が可能。
  • InstantClickはSEOに影響を与えず、ページの初回読み込み速度にも影響しない。

★Hugoブログでシステムのダーク/ライトモード自動切り替えを実現(PaperMod)

◇問題の背景

  • Hugo + PaperModテーマでは、デフォルトでページ読み込み時にのみシステムのダーク/ライトモード(prefers-color-scheme)を検出します。システムテーマを切り替えた後、ページを手動でリフレッシュしないと反映されず、ユーザー体験に影響を与えます。

◇実現目標

  • ユーザーがオペレーティングシステムのダーク/ライトモードを切り替えた際、ブログページがリフレッシュせずに自動的に同期して切り替わり、現代の主流ウェブサイトと同様の体験を提供する。

◇実現手順

  1. themes/PaperMod/layouts/partials/footer.html の末尾に以下のJSコードを挿入:

    <script>
    // システムのダーク/ライトモード切り替えに自動対応
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
      const isDark = e.matches;
      document.body.classList.toggle('dark', isDark);
      localStorage.setItem('pref-theme', isDark ? 'dark' : 'light');
      // カスタムテーマ切り替えボタンがある場合、ここで状態を同期可能
    });
    </script>
    
  2. 原理の説明

    • prefers-color-scheme: darkchange イベントを監視し、システムテーマが切り替わった際にページのテーマを自動更新。
    • localStorage を同期更新することで、ページリフレッシュ後もユーザーの選択が保持される。
  3. 注意事項

    • テーマ切り替えボタンがある場合、ボタンの状態が自動切り替えロジックと互換性を持つようにする。
    • このコードはすべての <script> の最後に配置することを推奨し、優先順位と互換性を確保する。

◇効果

  • システムテーマ(macOS、Windows、モバイル端末など)を切り替えると、ブログページがリフレッシュせずに即座に自動切り替えされ、ユーザー体験が向上する。

★音楽プレーヤー

◇実現目標

  • 画面右下に固定されたミニプレーヤー
  • プレイリストの展開/折りたたみ機能
  • 再生/一時停止、前の曲/次の曲のコントロールボタン
  • 記事を切り替えても音楽の再生状態を保持(localStorageを利用)
  • ガラスのような半透明背景デザイン(フロストガラス効果)

◇実装手順

  1. ファイル layouts/partials/musicplayer.html を作成:
<!-- layouts/partials/musicplayer.html -->
 
<style scoped>
  .music-player-container {
    position: fixed;
    bottom: 12px;
    right: 12px;
    z-index: 9999;
    font-family: system-ui, sans-serif;
    font-size: 13px;
  }

  .music-player-container .music-panel {
    display: none;
    margin-bottom: 6px;
    background: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.2);
    border-radius: 12px;
    padding: 12px 14px;
    width: 200px;
    max-height: 180px;
    overflow-y: auto;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
    backdrop-filter: blur(16px) saturate(180%);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
  }

  .music-player-container .music-panel.show {
    display: block;
  }

  .music-player-container .music-title {
    font-size: 13px;
    color: var(--primary, #333);
    font-weight: 500;
    margin-bottom: 6px;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  }

  .music-player-container .playlist {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .music-player-container .playlist li {
    padding: 4px 6px;
    border-radius: 6px;
    cursor: pointer;
    color: var(--content, #666);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
    transition: all 0.2s ease;
  }

  .music-player-container .playlist li:hover {
    background: rgba(255, 255, 255, 0.15);
    transform: translateY(-1px);
  }

  .music-player-container .playlist li.active {
    background: rgba(255, 255, 255, 0.2);
    color: var(--accent, #007acc);
    font-weight: 600;
    border: 1px solid rgba(255, 255, 255, 0.3);
  }

  .music-player-container .mini-player {
    background: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.2);
    border-radius: 100px;
    display: flex;
    gap: 8px;
    padding: 6px 10px;
    align-items: center;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
    backdrop-filter: blur(20px) saturate(180%);
    -webkit-backdrop-filter: blur(20px) saturate(180%);
    transition: all 0.3s ease;
  }

  .music-player-container .mini-player:hover {
    background: rgba(255, 255, 255, 0.15);
    transform: translateY(-2px);
    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
  }

  .music-player-container .mini-player button {
    background: none;
    border: none;
    font-size: 16px;
    color: var(--secondary, #888);
    cursor: pointer;
    padding: 4px;
    border-radius: 50%;
    transition: all 0.2s ease;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  }

  .music-player-container .mini-player button:hover {
    color: var(--accent, #007acc);
    background: rgba(255, 255, 255, 0.1);
    transform: scale(1.1);
  }

  .music-player-container .mini-player button:active {
    color: var(--accent-active, #005a99);
    transform: scale(0.95);
  }

  .music-player-container audio {
    display: none;
  }

  /* 滚动条美化 */
  .music-player-container .music-panel::-webkit-scrollbar {
    width: 4px;
  }

  .music-player-container .music-panel::-webkit-scrollbar-track {
    background: rgba(255, 255, 255, 0.1);
    border-radius: 2px;
  }

  .music-player-container .music-panel::-webkit-scrollbar-thumb {
    background: rgba(255, 255, 255, 0.3);
    border-radius: 2px;
  }

  .music-player-container .music-panel::-webkit-scrollbar-thumb:hover {
    background: rgba(255, 255, 255, 0.5);
  }
</style>

<div class="music-player-container" id="musicPlayerContainer">
  <div class="music-panel" id="musicPanel">
    <div class="music-title" id="musicTitle">Loading...</div>
    <ul class="playlist" id="musicPlaylist"></ul>
  </div>
  <div class="mini-player" id="miniPlayer">
    <button id="musicTogglePanel" title="Toggle Playlist">📂</button>
    <button id="musicPrevBtn" title="Previous">⏮️</button>
    <button id="musicPlayBtn" title="Play">▶️</button>
    <button id="musicNextBtn" title="Next">⏭️</button>
  </div>
  <audio id="musicAudio"></audio>
</div>

<script>
  (function() {
    'use strict';
    
    // 命名空间前缀,避免全局冲突
    const MUSIC_PLAYER_NS = 'HugoMusicPlayer_';
    
    const base = "https://yuuniji.github.io/music/lofi_beats/";
    const jsonURL = base + "songs.json";
    const STORAGE_KEY = MUSIC_PLAYER_NS + "state";
    const INTERACT_KEY = MUSIC_PLAYER_NS + "user_interacted";

    async function initMusicPanel() {
      try {
        const res = await fetch(jsonURL);
        const songs = await res.json();
        if (!songs.length) return;

        let currentIndex = 0;
        let isPlaying = false;
        
        // 使用带命名空间的ID选择器
        const audio = document.getElementById("musicAudio");
        const playBtn = document.getElementById("musicPlayBtn");
        const prevBtn = document.getElementById("musicPrevBtn");
        const nextBtn = document.getElementById("musicNextBtn");
        const toggleBtn = document.getElementById("musicTogglePanel");
        const panel = document.getElementById("musicPanel");
        const title = document.getElementById("musicTitle");
        const playlist = document.getElementById("musicPlaylist");

        // 检查元素是否存在
        if (!audio || !playBtn || !prevBtn || !nextBtn || !toggleBtn || !panel || !title || !playlist) {
          console.warn('Music player elements not found');
          return;
        }

        // 恢复保存的状态
        const savedStateStr = localStorage.getItem(STORAGE_KEY);
        if (savedStateStr) {
          try {
            const saved = JSON.parse(savedStateStr);
            if (saved.index >= 0 && saved.index < songs.length) {
              currentIndex = saved.index;
              audio.currentTime = saved.time || 0;
              isPlaying = saved.isPlaying || false;
            }
          } catch (e) {
            console.warn('Failed to parse saved music player state:', e);
          }
        }

        // 构建播放列表
        songs.forEach((song, index) => {
          const li = document.createElement("li");
          li.textContent = song.title;
          li.onclick = function() {
            currentIndex = index;
            loadAndPlay(currentIndex);
            localStorage.setItem(INTERACT_KEY, "true");
          };
          playlist.appendChild(li);
        });

        function highlight(index) {
          const listItems = playlist.querySelectorAll("li");
          listItems.forEach((li, idx) => {
            li.classList.toggle("active", idx === index);
          });
        }

        function loadAndPlay(index) {
          audio.src = base + songs[index].file;
          audio.currentTime = 0;
          audio.play().then(() => {
            isPlaying = true;
            playBtn.textContent = "⏸️";
            title.textContent = songs[index].title;
            highlight(index);
            saveState();
          }).catch((error) => {
            console.warn('Failed to play audio:', error);
            isPlaying = false;
            playBtn.textContent = "▶️";
          });
        }

        function saveState() {
          try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify({
              index: currentIndex,
              time: audio.currentTime,
              isPlaying: !audio.paused
            }));
          } catch (e) {
            console.warn('Failed to save music player state:', e);
          }
        }

        // 初始化显示
        audio.src = base + songs[currentIndex].file;
        title.textContent = songs[currentIndex].title;
        highlight(currentIndex);

        // 更新播放按钮状态的函数
        function updatePlayButton() {
          if (audio.paused) {
            playBtn.textContent = "▶️";
            isPlaying = false;
          } else {
            playBtn.textContent = "⏸️";
            isPlaying = true;
          }
        }

        // 事件监听器
        playBtn.onclick = function() {
          if (audio.paused) {
            audio.play().then(() => {
              updatePlayButton();
              saveState();
            }).catch((error) => {
              console.warn('Failed to play audio:', error);
              updatePlayButton();
            });
          } else {
            audio.pause();
            updatePlayButton();
            saveState();
          }
        };

        prevBtn.onclick = function() {
          currentIndex = (currentIndex - 1 + songs.length) % songs.length;
          loadAndPlay(currentIndex);
        };

        nextBtn.onclick = function() {
          currentIndex = (currentIndex + 1) % songs.length;
          loadAndPlay(currentIndex);
        };

        toggleBtn.onclick = function() {
          panel.classList.toggle("show");
        };

        // 音频事件监听
        audio.ontimeupdate = saveState;
        
        audio.onplay = function() {
          updatePlayButton();
          saveState();
        };
        
        audio.onpause = function() {
          updatePlayButton();
          saveState();
        };
        
        audio.onended = function() {
          currentIndex = (currentIndex + 1) % songs.length;
          loadAndPlay(currentIndex);
        };

        // 页面加载完成后尝试自动播放
        if (document.readyState === 'complete') {
          tryAutoPlay();
        } else {
          window.addEventListener("load", tryAutoPlay);
        }

        function tryAutoPlay() {
          audio.play().then(() => {
            updatePlayButton();
            saveState();
          }).catch(() => {
            updatePlayButton();
          });
        }

      } catch (error) {
        console.error('Failed to initialize music player:', error);
      }
    }

    // 确保DOM加载完成后再初始化
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', initMusicPanel);
    } else {
      initMusicPanel();
    }
  })();
</script>
  1. テンプレートにパーツを組み込む

layouts/_default/baseof.html または layouts/_default/single.html など、メインのレイアウトテンプレートの </body> タグの直前に以下のコードを追加してください:

{{ partial "musicplayer.html" . }}