Middelman + Google Spreadsheet を使ってみんなで編集できる静的サイトを作る

最近 Middleman を活用しています。middleman は Rails からテンプレートエンジン部分だけを抜き出したようなもので、Haml や Slick などを使いたいが、ログイン処理などの動的なものは必要ないといった時に便利です。

静的なファイルのみの出力なので、Amazon S3 や Github Pages に、生成された HTML をアップロードすることでホスティング費用を劇的に少なでき、Wordpress 等のCMSのようにセキュリティ面を気にする必要もなくなります。

ブログの機能も備わっている他、データファイルを読み込むことができるので、データを元にページを生成するような事も簡単にできるのが魅力です。

今回、Code for Japan のイベント Social Hack Day のウェブサイトを作る際にも Middleman を使わせてもらいました。

システム全体は以下のようになっています。
_SocialHackDay server architecture

SocialHackDay のソースコードは以下のリポジトリに公開しています。
https://github.com/codeforjapan/socialhackday

Google Spreadsheet を使ってデータを収集する

Social Hack Day には、イベントという概念とプロジェクトという概念があります。
イベントやプロジェクトは、サイトの管理者である私以外の 特定多数もデータを更新 できるようにしたいです。
しかし、Middleman にはCMS機能がないのが課題です。更新する人がみんな技術者であれば Github に Pull Request してね!と言えば良いのですが、さすがに普通の人にはハードルが高いです。
そこで、データの更新には Google Spreadsheet を使うことにしました。

Kobito.yld5hP.png
(実際のSpreadsheet)
認証は各自のGoogleアカウントになり、履歴も追えるので、いたずら編集も起きにくいです。誰でも更新できるのではなく、イベントに来たチームリーダーや、イベントを開催するオーガナイザーのみに権限を付与しています。GSuiteを使っていれば以前のバージョンに戻すこともできますので、万一の場合も安心です。

Spreadsheet の内容をJSONで取り出すAPIを作る

まず、Spreadsheet の内容をJSONで取り出せるようにします。
こちらの記事を参考にしました。
スクリプトエディタを使って、以下のようなコードを追加しました。type パラメータに projects があれば Projects シートの内容を、events があれば Events シートの内容をJSONで出力するようにしました。

コード.gs
function getData(id, sheetName) {
  var sheet = SpreadsheetApp.openById(id).getSheetByName(sheetName);
  var rows = sheet.getDataRange().getValues();
  var keys = rows.splice(0, 1)[0];
  return rows.map(function(row) {
    var obj = {}
    row.map(function(item, index) {
      obj[keys[index]] = item;
    });
    return obj;
  });
}

function doGet(e) {
  var sheetid = "";
  if (e.parameter['type'] == 'projects'){
    sheetid = 'Projects';
  }else if (e.parameter['type'] == 'events'){
    sheetid = 'Events';
  }else{
    return ContentService.createTextOutput('Error: type parameter was not set');
  }
  var data = getData('#{SpreadsheetのID}', sheetid);
  return ContentService.createTextOutput(JSON.stringify(data, null, 2))
  .setMimeType(ContentService.MimeType.JSON);
}

上記で作ったAPIにアクセスすると、以下のようなJSONが取り出せます。

exec?type=projects
[
    {
        project_name: "ハックデーサポートシステム",
        team_name: "Code for Japan HackDay Team",
        last_update: "2018-04-13T15:00:00.000Z",
        status: "Active",
        cover_image_url: "/assets/images/shd-image.jpg",
        leader: "Hal Seki",
        members: "",
        purpose: "ハックデーに興味のある人や参加した人が欲しいと思うシステムを準備したい。 まずはプロジェクトの一覧やイベント一覧が見れるサイトを作る",
        current_status: "HackMDである程度整理できるけど、ウェブサイトで成果を見せるようにしたい。 スタティックなウェブサイトのベースができた。",
        future_situation: "Code for Japan 全体の課題として、どんなプロジェクトがどこでどのようにやられていて、どのように参加したら良いのかちっともわからない。 将来的には、サイトに来たらどんなプロジェクトがあって、どんな人達がどんな目的でやっていて、どう参加できるのかがすぐにわかるようにしたい。",
        future_works: "ページ更新(関) プロジェクト一覧機能の実装 ログイン機能もつけたい",
        help_wanted: "開発者 デザイナー",
        project_page: "https://hackday.code4japan.org/",
        how_to_join: "Code for Japan Slack の #hackday-website チャンネル https://cfjslackin.herokuapp.com/"
    },...
]

middleman-data_source を使って、外部データをデータファイルとして使えるようにする

ここまでやれば、JavaScript を駆使して動的にデータを取ってきて表示することも可能なのですが、色々面倒ですし、SEO の観点からも望ましくありません。
元々 Middleman には、データファイル という仕組みがあり、json 形式のデータを利用してページのレンダリングを行なうことが可能です。この機能を、ローカルのデータファイルではなく外部のAPI経由で使えるようにするのが middleman-data_source プラグインです。
このプラグインを使って、先程作った API からデータを取ってくるようにします。

  • Gemfile に追加して導入
Gemfile
# 以下の行を追加
gem 'middleman-data_source'

  • config.rb で有効化
config.rb
# middleman-data_source を有効化する
activate :data_source do |c|
  c.root  = "https://script.google.com/macros/s/AKfycbzykqG-CZmFsrLmUhSvpnE-V9iR0VQDxcfG_y-o-QHEtV3ghZu3" #API のURL
  c.sources = [
    {
      alias: "projects",
      path: "/exec?type=projects",
      type: :json },
    {
      alias: "events",
      path: "/exec?type=events",
      type: :json },
  ]
end

alias は、実際にデータにアクセスするときの変数名として使えますので、上記を記述することで、ページ側から data.projects という名前でプロジェクト一覧が取り出せることになります。

レンダリングする

あとは、通常のデータファイルを使うときと同じように、HTML側に埋め込みます。例えば、イベントページでは以下のような記述になっています。

_event_event.html.haml
    - if data.events.count == 0
      .row
        .col-lg-3 直近で予定されているイベントはありません。
    - data.events.each do |e|
      - if (DateTime.parse(e.event_start) > DateTime.now())
        .row
          .col-lg-3
            - if e.event_image.blank?
              %a{:href=> "#{strip_tags(e.entry_page)}", :target => "_blank"}
                %img.img-fluid{:alt => "No Image", :src => "/assets/images/logo.png"}/
            - else
              %a{:href=> "#{strip_tags(e.entry_page)}", :target => "_blank"}
                %img.img-fluid{:alt => "#{strip_tags(e.event_name)}", :src => "#{strip_tags(e.event_image)}"}/
          .col-lg-7
            %p.lead #{event_time_from_to(e)}
            %a{:href=> "#{strip_tags(e.entry_page)}", :target => "_blank"}
              %h2 #{simple_format_with_span(e.event_name)}
            %p [#{simple_format_with_span(e.location)}] 会場:#{simple_format_with_span(e.venue)}
            %p #{simple_format_with_span(e.description)}
          .col-lg-2
            %a.btn.u-btn-darkgray{:href => "#{strip_tags(e.entry_page)}", :target => "_blank"} 参加する

注:simple_format_with_span() や event_time_from_to() は今回の為に作っているオリジナルの関数です。また、わかりやすくするために、スタイル定義などは削っています

Spreadsheet の内容をそのまま出力してしまうと、HTMLタグなどが入っていた場合に崩れてしまうので、上記のように strip_tags や simple_format を使ってサニタイズをするのを忘れないようにしましょう。

Kobito.dkI7QF.png
(実際にレンダリングされたHTML)

以上で、サイトがSpreadsheetの内容を元に出力されるようになりました。

デプロイ

あとは、CircleCI などで S3 にデプロイするようにすれば完成です。Github を使っているので、CircleCI を使ってデプロイをするように設定しました。

.circleci/config.yml
# Ruby CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-ruby/ for more details
#
version: 2
jobs:
  deploy:
    docker:
      # specify the version you desire here
       - image: circleci/ruby:2.4.1-node-browsers
      # Specify service dependencies here if necessary
      # CircleCI maintains a library of pre-built images
      # documented at https://circleci.com/docs/2.0/circleci-images/
      # - image: circleci/postgres:9.4

    working_directory: ~/repo

    steps:
      - checkout

      # Download and cache dependencies
      - restore_cache:
          keys:
          - v1-dependencies-gem-{{ checksum "Gemfile.lock" }}
          - v1-dependencies-gem-{{ checksum "package-lock.json" }}
          # fallback to using the latest cache if no exact match is found
          - v1-dependencies-

      - run:
          name: install dependencies
          command: |
            bundle install --jobs=4 --retry=3 --path vendor/bundle
            sudo apt-get update -yqqq
            sudo npm install
            sudo apt-get install python-pip python-dev build-essential
            sudo pip install awscli --upgrade

      - save_cache:
          paths:
            - ./vendor/bundle
          key: v1-dependencies-gem-{{ checksum "Gemfile.lock" }}
          paths:
            - ./node_modules
          key: v1-dependencies-gem-{{ checksum "package-lock.json" }}

      # Build
      - run:
          name: build phase
          command: |
            bundle exec middleman build --environment="production" --build-dir=public --clean

      # Clear Cloudfront save_cache
      - run:
          name: clear Cloudfront cache
          command: |
            aws configure set preview.cloudfront true
            aws s3 sync --sse --delete public s3://$AWS_BUCKET/
            aws cloudfront create-invalidation --distribution-id $CF_DISTRIBUTION_ID --paths '/*'
          environment:
            CF_DISTRIBUTION_ID: E2MMTLBMXPAMW5

workflows:
  version: 2
  build-deploy:
    jobs:
      - deploy:
          filters:
            branches:
              only: master

Social Hack Day の場合は Gulp や AWS CLI 、必要なライブラリのコピーなどを使っているので少々複雑になっています。場合によってカスタマイズしてください。

S3やCloudfrontの設定についてはこの記事では解説しませんが、以下のような記事が参考になります。
Github, CircleCI, S3, CloudFront, Middlemanを活用して、https対応のサーバレスで自動deployなWEBサイト構築
MiddlemanでbuildしたコンテンツをCircle CI経由でAmazon S3に同期する
ただし、CircleCI は 2.0 になってから circle.yml を使う形式ではなくなっているので気をつけましょう。

Middleman はビルド時にHTMLを生成する仕組みですので、実際にSpreadsheetの内容を反映するにはデプロイを行なう必要があります。
Social Hack Day のサイトの場合は、1日1回、CircleCI のAPIを使って自動でデプロイを行なうようにしています。

具体的には、別のサーバから cron で以下のリクエストを行なっています。

deploy.sh
curl -u #[APIキー}: \
     -d build_parameters[CIRCLE_JOB]=deploy \
     https://circleci.com/api/v1.1/project/github/codeforjapan/socialhackday/tree/master

APIキー の部分は適宜書き換えてください。

気になっていること

  • S3 にアップロードしたあとCloudfrontのキャッシュクリアしているが、やたら時間がかかる。更新があったものだけキャッシュクリアとかできない?

他にも、Issue を色々登録しているので、Pull Request いただけると大変ありがたいです!

告知

記事を最後まで読んでいただきありがとうございます。
サイトにもある通り、ソーシャルハックデーは、プロジェクトを持ち込んでみんなで何かを作る場です。
何かやりたい人、やっている人やチームがプロジェクトを持ち込んで、仲間を募り、みんなで手を動かしながらサービスをつくりあげる One day ハッカソンです。ハッカソンとは言っても、技術者やデザイナーなど、特定のスキルのある人だけしか参加できないイベントではありません。この日でプロトタイプを作り上げて終わりというものでもなく、継続的なものです。持ち込むものは新しいアイデアでなくてもかまいません。自分のやっていることを皆に伝え、共感をもってくれそうな仲間を集め、仲間が見つかったら一緒に作ってみる。1日頑張って、アウトプットができたら発表してみんなで祝う、「ともに考え、ともにつくる」場です。
初めての方も大歓迎ですし、Qiita を読んでいるような方はきっと貢献ができることがたくさんあります。
もちろん技術力の高いメンバーもおりますが、マサカリが飛んでくるような超ハイレベルなものではなく、毎回わいわいと楽しく開催していますので、是非気軽に遊びに来てください。

記事を書いた日の直近のソーシャルハックデー
2018年06月30日 10:30〜18:00

SOCIAL HACK DAY TOKYO VOL.2
[東京] 会場:SENQ霞が関

ソーシャルハックデーの第2回目。好評だった前回に引き続き、SENQ霞が関で行います。技術がわからない方でも、ぜひテーマを持ち込みに来てください。途中入場、途中退出も問題ありません!

申し込み(フェイスブックイベント)