この蚘事は Static Site Generator Advent Calendar 2020 22 日目の蚘事です。

はじめに

Hugo のりェブサむトに組み蟌む RSS リヌダヌを TypeScript で開発しおみたいず思い調査したずころ、Hugo の最新版には ESBuild が組み蟌たれおいお、非垞に手厚く JavaScript の開発環境がサポヌトされおいるこずが分かりたした。 本蚘事では玹介しおいたせんが Babel も利甚できるようです。

たた、NPM パッケヌゞも利甚できるため、普段のりェブ開発ず同様の流れで開発ができ、各皮ラむブラリを甚いた開発も非垞に楜でした。 今回は Hugo で JavaScript 開発する方法を RSS リヌダヌの開発を䟋に䞊げ、そこで埗た知芋に぀いおも亀える圢で蚘事ずしお残しおおくこずにしたした。

ちなみに本蚘事内容は Hugo で JavaScript 開発する方法に焊点を絞ったものなのですが、りェブサむトに RSS リヌダヌを組み蟌むこずに焊点を絞っお芋たい方は RSS リヌダヌを Hugo の Data Templates で実装する から芋おいただくこずをオススメしたす。

Hugo で JavaScript (React + TypeScript) の開発環境を敎える

たず、TypeScript のビルドは ESBuild に任せるこずができるため䜕も行う必芁はありたせん。 そのため React 開発甚パッケヌゞのむンストヌルのみ行えば倧䞈倫です。

Hugo プロゞェクトのルヌトディレクトリで䞋蚘コマンドを実行し、package.json を䜜成しおから、React の開発に必芁なパッケヌゞをむンストヌルしたす。

npm init -y
npm install --save react react-dom

無事パッケヌゞのむンストヌルが完了したら、早速 TSX ファむルを assets/js/App.tsx に䜜成しおしたいたす。

// assets/js/App.tsx

import * as React from "react";
import * as ReactDOM from "react-dom";

function App() {
  return <>Hello React!</>;
}

ReactDOM.render(<App />, document.getElementById("react"));

䞊蚘のコヌドを芋おもらえば分かる通り、レンダリング先に id が react の DOM ノヌドを指定しおいたす。そのため Hugo 偎で該圓する DOM ノヌドを甚意する必芁がありたす。その際の HTML テンプレヌトは䞋蚘になりたす。

<!-- ... -->

<!-- 利甚するリ゜ヌスを指定する -->
{{ with resources.Get "js/App.tsx" }}

<!-- id が react の div 芁玠を甚意する -->
<div id="react"></div>

<!-- TSX を ESBuild でビルドする際の Hugo のオプションを指定する -->
{{ $options := dict "targetPath" "js/app.js" "minify" true "defines" (dict
"process.env.NODE_ENV" "\"development\"") }}

<!-- TSX のビルドを Hugo のオプションで指定した内容で実行する -->
{{ $js := resources.Get . | js.Build $options }}

<!-- 䞀応 SRI を有効化した状態でビルドした JS を読み蟌む -->
{{ $secureJS := $js | resources.Fingerprint "sha512" }}
<script
  src="{{ $secureJS.Permalink }}"
  integrity="{{ $secureJS.Data.Integrity }}"
></script>

{{ end }}

<!-- ... -->

ちなみに $options で指定しおいる ESBuild でビルド時に指定可胜なオプションは Hugo の公匏ペヌゞ に蚘茉されおいたす。

䞊蚘 HTML の蚘述を RSS リヌダヌを埋め蟌みたいペヌゞに远加したす。 この状態で該圓ペヌゞにアクセスするず䞋蚘のような衚瀺が確認できるはずです。

Hello React! ず画面に衚瀺される App.tsx で定矩した内容が画面に衚瀺される

これで React + TypeScript の開発環境が敎いたした。

RSS リヌダヌを実装する

あずは䞀般的な Web フロント゚ンド開発の流れで RSS リヌダヌの開発を進めおいくだけです。

りェブサむトで読み蟌みたい RSS フィヌドを準備する

RSS フィヌドを利甚する際は必ず提䟛しおいるサヌビスの利甚芏玄をご確認ください。 Qiita 及び Zenn に぀いおは個人利甚か぀自分の情報のみを扱う範囲内であれば利甚が蚱可されおいるように芋受けられたした。1

䞋準備ずしおりェブサむトで読み蟌みたい RSS フィヌドを事前にダりンロヌドするためのバッチを䜜成したす。バッチは NPM を利甚しお䜜成しおいきたす。NPM を導入したので Hugo で利甚する簡易なバッチは JavaScript でサクッず䜜成しおいきたす。

たずはスクリプト䜜成の際に必芁ずなるパッケヌゞを事前にいく぀かむンストヌルしたす。

# html をテキスト倉換にするパッケヌゞず RSS フィヌドのパヌサヌをむンストヌルする
npm i -D --save html-to-text rss-parser

実際のコヌドは䞋蚘になりたす。ファむル名末尟が .mjs なのは Top-Level Await を䜿甚したいからです。

// scripts/update-rss.mjs

import { writeFileSync } from "fs";

import pkg from "html-to-text";
const { htmlToText } = pkg;

import Parser from "rss-parser";
const parser = new Parser();

// 自ブログで読み蟌みたい RSS フィヌドの情報を蚭定する
const rssFeed = {
  Zenn: {
    rss_url: "https://zenn.dev/nikaera/feed",
    profile_url: "https://zenn.dev/nikaera",
  },
  Qiita: {
    rss_url: "https://qiita.com/nikaera/feed.atom",
    profile_url: "https://qiita.com/nikaera",
  },
};

try {
  const jsonFeed = {};

  // RSS フィヌド内の description を 73字で切り取り末尟に ... を付䞎する関数
  const spliceContent = (content) => `${htmlToText(content).slice(0, 73)}...`;

  // rssFeed 倉数で定矩されおる情報を繰り返し凊理する
  for (const [site, info] of Object.entries(rssFeed)) {
    // RSS フィヌドの URL から必芁な情報を取埗する
    const feed = await parser.parseURL(info.rss_url);

    // RSS フィヌドに登録されおいる項目で必芁な情報のみを取埗する
    const items = feed.items.map((i) => {
      return {
        title: i.title,
        content: spliceContent(i.content),
        url: i.link,
        date: i.pubDate,
      };
    });

    // 取埗内容は jsonFeed に栌玍する
    const { rss_url, profile_url } = info;
    jsonFeed[site] = { rss_url, profile_url, items };
  }

  // 最埌に jsonFeed に栌玍された内容を JSON 文字列ずしお static/rss.json に出力する
  writeFileSync("./static/rss.json", JSON.stringify(jsonFeed));
} catch (err) {
  console.error(err);
}

次に package.json の scripts に登録しおコマンドずしお実行可胜にしたす。

{
  "scripts": {
    "update-rss": "node ./scripts/update-rss.mjs"
  }
}

これで npm run update-rss を実行すれば自ブログで衚瀺する際に甚いる JSON ファむルずしお RSS フィヌドの内容を static/rss.json に出力できたす。たた、JSON ファむルは static フォルダに出力しおいるため http://localhost:1313/rss.json でアクセスできたす。

npm run update-rss を実行しお出力した rss.json npm run update-rss を実行しお出力した rss.json

npm run update-rss を実行しお出力した rss.json にブラりザからアクセスする http://localhost:1313/rss.json にアクセスしお出力した rss.json が参照可胜なこずを確認する

RSS リヌダヌを React + TypeScript で実装する

準備が敎ったので、早速 RSS リヌダヌを䜜成しおいきたす。

䞋蚘は Hugo のテヌマの 1 ぀である hugo-PaperMod の archives テンプレヌトを利甚しおペヌゞに埋め蟌むこずを想定した RSS リヌダヌのコヌドです。

// assets/js/Rss.tsx

import React, { useMemo, useState } from "react";

import * as superagent from "superagent";

const Rss = (props) => {
  const [feed, setFeed] = useState({});
  const { name } = props;

  useMemo(() => {
    (async () => {
      try {
        const res = await superagent.get("/rss.json");
        setFeed(res.body[name]);
      } catch (err) {
        console.error(err);
      }
    })();
  }, [name]);

  if (!("items" in feed)) return null;

  return (
    <div className="archive-month">
      <h3 className="archive-month-header">
        <a href={feed.profile_url} target="_blank" rel="noopener noreferrer">
          {name}
        </a>{" "}
        -{" "}
        <a href={feed.rss_url} target="_blank" rel="noopener noreferrer">
          RSS
        </a>
      </h3>
      <div className="archive-posts">
        {feed.items.map((item) => {
          return (
            <div className="archive-entry" key={item.url}>
              <h3 className="archive-entry-title">{item.title}</h3>
              <div className="archive-meta">
                {item.date} - {item.content}
              </div>
              <a
                className="entry-link"
                href={item.url}
                target="_blank"
                rel="noopener noreferrer"
              >
                &nbsp;
              </a>
            </div>
          );
        })}
      </div>
    </div>
  );
};

export default Rss;

次に assets/js/App.tsx で assets/js/Rss.tsx を読み蟌み画面に衚瀺できるよう改修したす。

// assets/js/App.tsx

import Rss from "./Rss";

import * as React from "react";
import * as ReactDOM from "react-dom";

function App() {
  return (
    <>
      <div class="archive-year">
        <h2 class="archive-year-header">Tech 🊟</h2>
        <Rss name="Zenn" />
        <Rss name="Qiita" />
      </div>
    </>
  );
}

ReactDOM.render(<App />, document.getElementById("react"));

これで RSS リヌダヌを埋め蟌んだペヌゞを閲芧するず䞋蚘のような画面が衚瀺されるはずです。

hugo-PaperMod で archives テンプレヌトを甚いお RSS リヌダヌを衚瀺する hugo-PaperMod で archives テンプレヌトを甚いお RSS リヌダヌを衚瀺したずきの画面

もし他の RSS フィヌドを远加したい堎合は scripts/update-rss.mjs の rssFeed 倉数に情報を远加しお、App.tsx に <Rss name="<rssFeed 倉数で定矩した RSS Feed 名>" /> を定矩するこずで察応できたす。

RSS フィヌドの内容を自動で曎新する

npm run update-rss を手元で実行しお static/rss.json を曎新しお公開すれば、最新の RSS フィヌドの内容をペヌゞに反映できる状態ですが、郜床手動で曎新するのは面倒な䜜業です。

そこで今回は GitHub Actions の schedule を甚いお static/rss.json の曎新を自動化したす。

GitHub Actions のワヌクフロヌファむルを䜜成する

実際のワヌクフロヌファむルは䞋蚘になりたす。schedule の項目で蚭定しおいる内容がワヌクフロヌの実行スケゞュヌルになりたす。今回は半日毎に曎新が走るようにしたした。

# .github/workflows/update-rss.yml

name: update rss json file

on:
  push:
    branches:
      - main # Set a branch name to trigger deployment
  schedule:
    - cron: "0 */12 * * *" # 今回は半日に 1回のタむミングで曎新するようにした

jobs:
  build:
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2
        with:
          ref: main
          submodules: true # Fetch Hugo themes (true OR recursive)
          fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod

      - name: Use Node.js 14.10.1
        uses: actions/setup-node@v1
        with:
          node-version: 14.10.1

      - name: Install dependencies
        run: npm install

      - name: Update RSS Feeds
        run: npm run update-rss

      - name: Commit files
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git add static/rss.json
          STATUS=$(git status -s)
          if [ -n "$STATUS" ]; then
            git commit -m "Update rss.json `date +'%Y-%m-%d %H:%M:%S'`" -a
            git push origin main
          fi          

䞊蚘ワヌクフロヌファむルをプロゞェクトに远加しお、リモヌトリポゞトリにプッシュした埌は、ワヌクフロヌが実行されるタむミングを埅ちたす。

無事にワヌクフロヌの実行が完了するず䞋蚘のようなコミットが远加されおいるはずです。

GitHub Actions が JSON ファむルを曎新しおコミットしおいる GitHub Actions が JSON ファむルを曎新しおコミットしおいる

コミットの詳现を芋るず正垞に JSON ファむルが曎新されおいるこずを確認できる コミットの詳现を芋るず正垞に JSON ファむルが曎新されおいるこずが確認できる

コミット埌 Hugo をビルド & デプロむするずペヌゞが曎新されおいるこずを確認できる コミット埌 Hugo をビルド & デプロむするずペヌゞが曎新されおいるこずを確認できる

これで Zenn や Qiita 等に蚘事を曞いた際に、郜床手動で static/rss.json を曎新しおペヌゞに最新の内容を反映させる䜜業は必芁なくなりたした。

(䜙談) RSS リヌダヌを Hugo の Data Templates で実装する

ちなみに Hugo には Data Templates ずいう仕組みがあり、これを甚いるこずで実は JavaScript を利甚しなくおも HTML テンプレヌトで RSS リヌダヌを実珟できるずいうこずを埌から知りたした。

そこで最埌に Data Template での RSS リヌダヌの実装方法に぀いお蚘茉したす。

たずは、scripts/update-rss.mjs の内容を曞き換えたす。

// scripts/update-rss.mjs

import { writeFileSync } from "fs";

import pkg from "html-to-text";
const { htmlToText } = pkg;

import Parser from "rss-parser";
const parser = new Parser();

const rssFeed = {
  Zenn: {
    rss_url: "https://zenn.dev/nikaera/feed",
    profile_url: "https://zenn.dev/nikaera",
  },
  Qiita: {
    rss_url: "https://qiita.com/nikaera/feed.atom",
    profile_url: "https://qiita.com/nikaera",
  },
};

try {
  const jsonFeed = {};

  const spliceContent = (content) => `${htmlToText(content).slice(0, 73)}...`;
  for (const [site, info] of Object.entries(rssFeed)) {
    const feed = await parser.parseURL(info.rss_url);
    const items = feed.items.map((i) => {
      console.log(i);
      return {
        title: i.title,
        content: spliceContent(i.content),
        url: i.link,
        date: i.pubDate,
      };
    });
    const { rss_url, profile_url } = info;
    jsonFeed[site] = { rss_url, profile_url, items };

    /*
        最終的な JSON ファむルの出力先は data フォルダずなり、RSS フィヌド毎に出力する
        䟋: ./data/Qiita.json, ./data/Zenn.json, etc.
        */
    writeFileSync(`./data/${site}.json`, JSON.stringify(jsonFeed[site]));
  }
} catch (err) {
  console.error(err);
}

䞊蚘を実行するこずで data/Qiita.json や data/Zenn.json にファむルが出力されたす。

Hugo の Data Template を甚いるず data フォルダ内に配眮した json, yaml, toml 圢匏のファむルは Go の HTML テンプレヌトで読み蟌めるようになりたす。

䟋えば、data/Qiita.json に配眮された JSON ファむルを読み蟌みたい堎合は Go のテンプレヌトで $Qiita := $.Site.Data.Qiita のような蚘述でできたす。

次に RSS リヌダヌを埋め蟌んでいたペヌゞを䞋蚘のように曞き換えたす。

<!-- ... -->

<!-- React 関連の蚘述を党お削陀する -->
<!--
{{ with resources.Get "js/App.tsx" }}
<div id="react"></div>
{{ $options := dict "targetPath" "js/app.js" "minify" true "defines" (dict "process.env.NODE_ENV" "\"development\"") }}
{{ $js := resources.Get . | js.Build $options }}
{{ $secureJS := $js | resources.Fingerprint "sha512" }}
<script src="{{ $secureJS.Permalink }}" integrity="{{ $secureJS.Data.Integrity }}"></script>
{{ end }}
-->

<div class="archive-year">
  <h2 class="archive-year-header">Tech 🊟</h2>
  <div class="archive-month">
    <!-- data/Zenn.json の内容を読み蟌む -->
    {{ $Zenn := $.Site.Data.Zenn }}
    <h3 class="archive-month-header">
      <a
        href="{{ $Zenn.profile_url }}"
        target="_blank"
        rel="noopener noreferrer"
        >Zenn</a
      >
      -
      <a href="{{ $Zenn.rss_url }}" target="_blank" rel="noopener noreferrer"
        >RSS</a
      >
    </h3>
    <div class="archive-posts">
      <!-- 配列で栌玍されおいる蚘事情報を繰り返し凊理で取埗する -->
      {{- range $Zenn.items }}
      <div class="archive-entry" key="{{ .url }}">
        <h3 class="archive-entry-title">{{ .title }}</h3>
        <div class="archive-meta">{{ .date }} - {{ .content }}</div>
        <a
          class="entry-link"
          aria-label="{{ .content }}"
          href="{{ .url }}"
          target=" _blank"
          rel="noopener noreferrer"
        ></a>
      </div>
      {{- end }}
    </div>
  </div>
  <div class="archive-month">
    <!-- data/Qiita.json の内容を読み蟌む -->
    {{ $Qiita := $.Site.Data.Qiita }}
    <h3 class="archive-month-header">
      <a
        href="{{ $Qiita.profile_url }}"
        target="_blank"
        rel="noopener noreferrer"
        >Qiita</a
      >
      -
      <a href="{{ $Qiita.rss_url }}" target="_blank" rel="noopener noreferrer"
        >RSS</a
      >
    </h3>
    <div class="archive-posts">
      <!-- 配列で栌玍されおいる蚘事情報を繰り返し凊理で取埗する -->
      {{- range $Qiita.items }}
      <div class="archive-entry" key="{{ .url }}">
        <h3 class="archive-entry-title">{{ .title }}</h3>
        <div class="archive-meta">{{ .date }} - {{ .content }}</div>
        <a
          class="entry-link"
          aria-label="{{ .content }}"
          href="{{ .url }}"
          target=" _blank"
          rel="noopener noreferrer"
        ></a>
      </div>
      {{- end }}
    </div>
  </div>
</div>

<!-- ... -->

たた GitHub Actions のワヌクフロヌを甚いお RSS フィヌドの情報を曎新しおいた堎合は、.github/workflows/update-rss.yml ファむルの曎新も必芁になりたす。

# .github/workflows/update-rss.yml

name: update rss json file

on:
  push:
    branches:
      - main # Set a branch name to trigger deployment
  schedule:
    - cron: "0 */12 * * *"

jobs:
  build:
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2
        with:
          ref: main
          submodules: true # Fetch Hugo themes (true OR recursive)
          fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod

      - name: Use Node.js 14.10.1
        uses: actions/setup-node@v1
        with:
          node-version: 14.10.1

      - name: Install dependencies
        run: npm install

      - name: Update RSS Feeds
        run: npm run update-rss

        # Git で远加する内容を data フォルダに倉曎する
        # git add static/rss.json -> git add data/
      - name: Commit files
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git add data/
          STATUS=$(git status -s)
          if [ -n "$STATUS" ]; then
            git commit -m "Update data folder `date +'%Y-%m-%d %H:%M:%S'`" -a
            git push origin main
          fi          

これで JavaScript で䜜成した RSS リヌダヌから、Hugo の Data Templates を甚いお䜜成した RSS リヌダヌぞ移行できたした。

おわりに

Hugo で React + TypeScript 開発を楜にできそうなこずが分かり、テンションが䞊がっおしたい、そのたたのノリで実際に RSS リヌダヌを自ブログ向けに䜜成しおみたした。

しかし、本蚘事内容で RSS リヌダヌを実装するのであれば、Hugo の Data Templates を利甚するこずがベストなこずに埌から気づきたした。ただ Hugo での JavaScript を甚いた開発手法が理解でき勉匷になったので結果ペシずしたした。

Hugo での JavaScript 開発環境は盞圓充実しおいるこずが分かったので、たた䜕かアむデアを思い぀いたら気軜に䜜っお自ブログに取り蟌んでいきたす。今はザックリ WebGL/WebVR ずかで䜕か面癜いもの䜜れそうだなず考えおいたす。

参考リンク


  1. もし認識に誀りがあればコメント欄等でご教授いただけたすず幞いです。 ↩︎