大企業病的サイトジェネレーター

大企業人が泣いて喜ぶであろう静的サイトジェネレーターを作る話です。


## モチベーション


私は常々ポートフォリオサイトを作りたいと思っていまして、
最初は静的サイトジェネレーターで作ろうと思い、人気の GatsbyJS や Hugo のテンプレートを色々みてたわけですが、


例えば GatsbyJS のスターターだと [gatsby-starter-cv](https://www.gatsbyjs.org/starters/santosfrancisco/gatsby-starter-cv/) とか
[![gatsby-starter-cv](https://www.gatsbyjs.org/static/95f1e0123427b7927a2d6fdcd95c5603/7641f/050264054bdf5830951979313945758b.png)](https://www.gatsbyjs.org/starters/santosfrancisco/gatsby-starter-cv/)


Hugo のテーマだと [Split](https://themes.gohugo.io/hugo-split-theme/) とか
[![Split](https://d33wubrfki0l68.cloudfront.net/099629a6c2d7e85ddd08d6cb26bea8aa76d0c9e4/448a8/hugo-split-theme/tn-featured-hugo-split-theme_hu4ed0d49fd7c17e31cb9c0d55fc7876d9_162513_768x512_fill_catmullrom_top_2.png)](https://themes.gohugo.io/hugo-split-theme/)


など、クールな個人サイト用デザインがあったりしたわけですが、
大企業のしがないエンタープライザーである私にはちょっとクール過ぎるデザインだなと思ったので、
結局デザインを作ることにしまして、ただ Figma とかみたいなデザインツールは使えないですし、
普段よく使うツールでやろうということで Google スプレッドシート でデザインしました。
職業柄ちょっと設計書っぽいサイトデザインになってしまいましたが、こんな感じです。
![Screen Shot 2019-01-25 at 18.53.24.png](https://qiita-image-store.s3.amazonaws.com/0/142668/d7d45931-4065-9d24-cc24-77b61cd4fe86.png)
方眼紙デザインメソドロジーを大胆に取り入れたこだわりのデザインです。安心感がありますよね。


というわけで、方眼紙デザインの Web サイトを作るため、
Google スプレッドシートを HTML に変換し、
Firebase Hosting にデプロイする
Google Apps Script 製静的サイトジェネレーターを作りました。

ソースはこちら: https://github.com/boiyaa/gas-sitegen
(security alerts ・・)


## 実装 Tips


### Google Apps Script のキャッチアップ

[Codelabs](https://codelabs.developers.google.com/codelabs/apps-script-intro/)
[GAS ビギナーが GAS を使いこなすために知るべきこと 10 選](https://qiita.com/tanabee/items/2c51681396fe12b6a0e4)
[Google Apps Script をローカル環境で快適に開発するためのテンプレートを作りました](https://qiita.com/howdy39/items/0e799a9bfc1d3bccf6e5)
[Apps Script services リファレンス](https://developers.google.com/apps-script/reference/)


### スプシを HTML 出力する
スプシの機能に「形式を指定してダウンロード」というのがあり、ここで Zip 圧縮 HTML に変換することができますが、
Apps Script services ではこのインターフェースが提供されていないので、 [Google Drive REST API の File:export](https://developers.google.com/drive/api/v3/reference/files/export) を [UrlFetchApp.fetch](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app#fetch(String,Object)) で呼び出します。

エクスポート形式の指定方法は [Downloading Google Documents](https://developers.google.com/drive/api/v3/manage-downloads#downloading_google_documents) に書いてあります。


```ts
declare var global: any;

global.build = () => {
  DriveApp.getStorageUsed();

  const blob = exportSheetAsZippedHTML('<file_id>');

  // とりあえずドライブの指定フォルダに保存する処理。最終的には不要。
  DriveApp.getFolderById('<folder_id>').createFile(
    (blob as unknown) as GoogleAppsScript.Base.BlobSource
  );
};

const exportSheetAsZippedHTML = fileId =>
  UrlFetchApp.fetch(
    `https://www.googleapis.com/drive/v3/files/${fileId}/export?mimeType=application%2Fzip`,
    {
      method: 'get',
      headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` }
    }
  ).getBlob();
```

参考: [using Google Apps Script: how to convert/export a Drive file?](https://stackoverflow.com/a/42925916)


試してみましょう。
適当にテンプレートからスプシを作成し、アドレスバーからファイルIDを取得し、
![Screen Shot 2018-12-14 at 10.29.57.png](https://qiita-image-store.s3.amazonaws.com/0/142668/0b1d9e2e-d884-d495-5fab-4e250a262b4c.png)


出力先にしたいフォルダを開き、アドレスバーからフォルダIDを取得し、
![Screen Shot 2019-01-25 at 17.38.35.png](https://qiita-image-store.s3.amazonaws.com/0/142668/01311365-f654-85f5-9633-06a217f504ca.png)


上記スクリプトにファイルIDとフォルダIDを記入して実行すると、 Zipファイルが生成されます。
![Screen Shot 2019-01-25 at 17.44.35.png](https://qiita-image-store.s3.amazonaws.com/0/142668/9073cd54-e2fb-1f92-b6b6-f76501c70e94.png)


Zipの中にはシート毎のHTMLファイルとCSSファイルが入っています。
![Screen Shot 2019-01-25 at 17.45.26.png](https://qiita-image-store.s3.amazonaws.com/0/142668/3a43ae3c-9b23-dff4-466a-1672c834cd63.png)

シート名=ファイル名になっているので、ホスティング用のスプシにはindexという名前のシートを作るといいでしょう。


### Zip 解凍する
[Utilities.unzip(blob)](https://developers.google.com/apps-script/reference/utilities/utilities#unzipblob) を使います。

```diff
  const blob = exportSheetAsZippedHTML('<file_id>');
+ const blobs = Utilities.unzip((blob as unknown) as GoogleAppsScript.Base.BlobSource);

+ blobs.forEach(blob => {
    DriveApp.getFolderById('<folder_id>').createFile(
      (blob as unknown) as GoogleAppsScript.Base.BlobSource
    );
+ });
```


中身が出力されました。
![Screen Shot 2019-01-25 at 17.48.46.png](https://qiita-image-store.s3.amazonaws.com/0/142668/9e3e8226-88ff-dbfa-a8f6-238aa690800d.png)



### Firebase Hosting にデプロイ
公開したいだけなら、スプシで全員共有とか、GAS で HTML 公開（[HTML Service: Create and Serve HTML](https://developers.google.com/apps-script/guides/html/)）とかありますが、 URL 長くなりますし、Google にインデックスされなさそうなので今回は用途的に見送りました。

ということで Google つながりで [Firebase Hosting](https://firebase.google.com/docs/hosting/) にデプロイします。


Firebase Hosting には [REST API](https://firebase.google.com/docs/hosting/reference/rest/) を使用してデプロイすることができます。
Firebase CLI を使用したデプロイの簡単さに定評のある Firebase Hosting ですが、 API でのデプロイ手順は結構複雑です。

1. OAuth2 アクセストークンを生成
2. 新しいバージョンを作成
3. ファイルを Gzip
4. Gzip ファイルの SHA256 ハッシュを取得
5. ファイルをバージョンに追加し、アップロード先情報を取得
6. ファイルをアップロード
7. バージョンのステータスを FINALIZED に更新
8. リリースの作成


[Deploy to your site using the Hosting REST API](https://firebase.google.com/docs/hosting/api-deploy) に Node.js での実装方法が書いてありますので、これを参考に GAS に置き換えます。

事前に上記手順 Step 1 のサービスアカウントの秘密鍵の生成までやっておきましょう。


#### OAuth2 アクセストークンを生成する
Node.js であれば Google API のアクセストークン生成は [Google API Client Library](https://github.com/googleapis/google-api-nodejs-client) を使用しますが、GAS なので [Apps Script ライブラリ](https://developers.google.com/apps-script/guides/libraries) (Node.js でいう npm pacakage 的な存在) の [OAuth2](https://github.com/gsuitedevs/apps-script-oauth2) を使用してアクセストークンを生成します。


ライブラリの追加は画面からもできますが、[appsscript.json](https://developers.google.com/apps-script/concepts/manifests) (Node.js でいう package.json 的な存在) に OAuth2 を追加します。

```diff:appsscript.json
{
  "timeZone": "UTC",
  "dependencies": {
+   "libraries": [
+     {
+       "userSymbol": "OAuth2",
+       "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF",
+       "version": "31"
+     }
+   ]
  },
  "exceptionLogging": "STACKDRIVER"
}
```


続いて実装します。

```ts
  // 上記手順で生成したサービスアカウントの秘密鍵
  const key = {
    type: 'service_account',
    project_id: '[PROJECT-ID]',
    private_key_id: '[KEY-ID]',
    private_key: '-----BEGIN PRIVATE KEY-----\n[PRIVATE-KEY]\n-----END PRIVATE KEY-----\n',
    client_email: '[SERVICE-ACCOUNT-EMAIL]',
    client_id: '[CLIENT-ID]',
    auth_uri: 'https://accounts.google.com/o/oauth2/auth',
    token_uri: 'https://oauth2.googleapis.com/token',
    auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs',
    client_x509_cert_url:
      'https://www.googleapis.com/robot/v1/metadata/x509/[SERVICE-ACCOUNT-EMAIL]'
  };

  // アクセストークンを生成
  const accessToken = OAuth2.createService(`Firebase Hosting: ${key.project_id}`)
    .setAuthorizationBaseUrl(key.auth_uri)
    .setTokenUrl(key.token_uri)
    .setPrivateKey(key.private_key)
    .setIssuer(key.client_email)
    .setPropertyStore(PropertiesService.getScriptProperties())
    // 最初APIドキュメントを見て firebase.hosting に設定したが、実際は cloud-platform でないと拒否された
    .setScope('https://www.googleapis.com/auth/cloud-platform')
    .getAccessToken();
```

参考: [Authenticate with a Service Account](https://sites.google.com/site/scriptsexamples/new-connectors-to-google-services/firebase/tutorials/authenticate-with-a-service-account)


#### 新しいバージョンを作成
[versions.create](https://firebase.google.com/docs/hosting/reference/rest/v1beta1/sites.versions/create) を呼び出します。

```ts
const version = JSON.parse(
  UrlFetchApp.fetch(
    `https://firebasehosting.googleapis.com/v1beta1/sites/${key.project_id}/versions`,
    {
      method: 'post',
      headers: { Authorization: `Bearer ${accessToken}` }
    }
  ).getContentText()
);
```

この API は以下のレスポンスを返すので、以降の処理で name を使用します。

```json
{
  "name": "sites/site-name/versions/version-id",
  "status": "CREATED",
  "config": {}
}
```


#### ファイルを Gzip
ファイルアップロードは Gzip 形式という仕様なので、 [Utilities.gzip(blob)](https://developers.google.com/apps-script/reference/utilities/utilities#gzipblob) を使います。

```ts
const gzipBlob = Utilities.gzip((blob as unknown) as GoogleAppsScript.Base.BlobSource);
```


#### Gzip ファイルの SHA256 ハッシュを取得
[Utilities.computeDigest(algorithm, value)](https://developers.google.com/apps-script/reference/utilities/utilities#computedigestalgorithm-value) を使います。
computeDigest の戻り値は Byte[] なので、 `openssl dgst -sha256` の出力と同じ形に変換します。

```ts
const hash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, gzipBlob.getBytes())
  .map(dec => ('00' + (dec < 0 ? dec + 256 : dec).toString(16)).slice(-2))
  .join('');
```


#### ファイルをバージョンに追加し、アップロード先情報を取得
[versions.populateFiles](https://firebase.google.com/docs/hosting/reference/rest/v1beta1/sites.versions/populateFiles) を呼び出し、上記で作成した SHA256 ハッシュを渡します。

```ts
const uploadInfo = JSON.parse(
  UrlFetchApp.fetch(
    `https://firebasehosting.googleapis.com/v1beta1/${version.name}:populateFiles`,
    {
      method: 'post',
      headers: { Authorization: `Bearer ${accessToken}` },
      contentType: 'application/json',
      payload: JSON.stringify({
        "files": {
          "/file1": "66d61f86bb684d0e35f94461c1f9cf4f07a4bb3407bfbd80e518bd44368ff8f4",
          "/file2": "490423ebae5dcd6c2df695aea79f1f80555c62e535a2808c8115a6714863d083",
          "/file3": "59cae17473d7dd339fe714f4c6c514ab4470757a4fe616dfdb4d81400addf315"
        }
      })
    }
  ).getContentText()
);
```

この API は以下のレスポンスを返します。

```json
{
  "uploadRequiredHashes": [
    "490423ebae5dcd6c2df695aea79f1f80555c62e535a2808c8115a6714863d083",
    "59cae17473d7dd339fe714f4c6c514ab4470757a4fe616dfdb4d81400addf315"
  ],
  "uploadUrl": "https://upload-firebasehosting.googleapis.com/upload/sites/site-name/versions/version-id/files"
}
```


#### ファイルをアップロード
上記 API レスポンスの uploadUrl へ uploadRequiredHashes に該当する Gzip ファイルを送信します。

```ts
UrlFetchApp.fetch(`${uploadInfo.uploadUrl}/${uploadRequiredHash}`, {
  method: 'post',
  headers: { Authorization: `Bearer ${accessToken}` },
  contentType: 'application/octet-stream',
  payload: gzipBlob.getBytes()
});
```


#### バージョンのステータスを FINALIZED に更新
[versions.patch](https://firebase.google.com/docs/hosting/reference/rest/v1beta1/sites.versions/patch) を呼び出します。

```ts
const finalizedVersion = JSON.parse(
  UrlFetchApp.fetch(
    `https://firebasehosting.googleapis.com/v1beta1/${version.name}?update_mask=status`,
    {
      method: 'patch',
      headers: { Authorization: `Bearer ${accessToken}` },
      contentType: 'application/json',
      payload: JSON.stringify({ status: 'FINALIZED' })
    }
  ).getContentText()
);
```


### リリースの作成
[releases.create](https://firebase.google.com/docs/hosting/reference/rest/v1beta1/sites.releases/create) を呼び出すことで、一連のデプロイ処理が完了し、ブラウザでアクセスできる状態になります

```ts
const release = JSON.parse(
  UrlFetchApp.fetch(
    `https://firebasehosting.googleapis.com/v1beta1/sites/${
      key.project_id
    }/releases?versionName=${version.name}`,
    {
      method: 'post',
      headers: { Authorization: `Bearer ${accessToken}` }
    }
  ).getContentText()
);
```

デプロイされました。
![Screen Shot 2019-01-25 at 19.43.20.png](https://qiita-image-store.s3.amazonaws.com/0/142668/d1a735bb-9f25-7b71-2467-27b456afcc4d.png)

まさかのハイパフォーマンス。
![Screen Shot 2019-01-25 at 17.03.23.png](https://qiita-image-store.s3.amazonaws.com/0/142668/72ee96a0-b110-b9d4-d020-a653a934c37f.png)


### シートタブをつける

やはりエンタープライズドキュメントとしては、表紙シートと更新履歴シートは捨てがたいので、シートタブを付けたいです。

編集画面のHTMLの構造は以下のようになっています。

![inspected.png](https://qiita-image-store.s3.amazonaws.com/0/142668/f6bb68d1-6200-30cd-72bb-4fb6a642b17e.png)

HTMLエクスポートは `.grid-container` 以下の要素を出力するので、外側にいるシートタブ (`#grid-bottom-bar`) はなくなります。

これだとせっかく各シートをHTMLファイルに出力しているのに、シート間の移動ができなくて不便なので、HTMLエクスポート後にシートタブを埋め込む処理を追加します。

### シートの並び順を取得
エクスポートされた状態だとシートの並び順がわかりませんので、 [Spreadsheet Service](https://developers.google.com/apps-script/reference/spreadsheet/) を使ってシート一覧を取得してみて、順序通りに取得できるのか試してみます。

シート1~4を作って適当に並び替えて、
<img width="263" alt="Screen Shot 2019-02-20 at 10.20.57.png" src="https://qiita-image-store.s3.amazonaws.com/0/142668/0c772a45-24f0-453d-8fc1-60569e1b7f56.png">

実行してみると、

```ts
declare var global: any;

global.getSheetNames = () => {
  const sheets = SpreadsheetApp.openById(<DRIVE_FILE_ID>).getSheets();
  const sheetNames = sheets.map(sheet => sheet.getName());
  Logger.log(sheetNames); // [3, 4, 1, 2]
};
```

順序通りに取得できるようですね。


### HTMLを書き換える

では次は、各シートファイルへのリンクを貼ったシートタブを、エクスポートしたHTMLに埋め込みます。

シートタブのHTMLの用意は、あまりコーディングに時間かけたくないのでDevToolでそのままコピってきて、不要な部分を削って調整しました。

```html
<style>.docs-sheet-tab{height: 100%;padding: 10px 4px 0 16px;}</style><table id="grid-bottom-bar" style="position: fixed; bottom: 0px; z-index: 1000;" class="grid-bottom-bar " cellspacing="0" cellpadding="0" dir="ltr" role="presentation"><tbody><tr><td style="width:120px;">&nbsp;</td><td class="docs-sheet-outer-container"><div class="goog-inline-block"><div class="docs-sheet-container goog-inline-block" style="height: 39px;"><div class="docs-sheet-container-bar goog-toolbar goog-inline-block" role="toolbar" style="user-select: none;"><a href="3.html" class="goog-inline-block docs-sheet-tab docs-material docs-sheet-active-tab" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" id=":6qd"><div class="goog-inline-block docs-sheet-tab-outer-box"><div class="goog-inline-block docs-sheet-tab-inner-box"><div class="goog-inline-block docs-sheet-tab-caption"><span dir="ltr" class="docs-sheet-tab-name" spellcheck="false">3</span></div></div></div></a><a href="4.html" class="goog-inline-block docs-sheet-tab docs-material" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" id=":6qu"><div class="goog-inline-block docs-sheet-tab-outer-box"><div class="goog-inline-block docs-sheet-tab-inner-box"><div class="goog-inline-block docs-sheet-tab-caption"><span dir="ltr" class="docs-sheet-tab-name" spellcheck="false">4</span></div></div></div></a><a href="1.html" class="goog-inline-block docs-sheet-tab docs-material" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" id=":6rb"><div class="goog-inline-block docs-sheet-tab-outer-box"><div class="goog-inline-block docs-sheet-tab-inner-box"><div class="goog-inline-block docs-sheet-tab-caption"><span dir="ltr" class="docs-sheet-tab-name" spellcheck="false">1</span></div></div></div></a><a href="2.html" class="goog-inline-block docs-sheet-tab docs-material" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" id=":6rs"><div class="goog-inline-block docs-sheet-tab-outer-box"><div class="goog-inline-block docs-sheet-tab-inner-box"><div class="goog-inline-block docs-sheet-tab-caption"><span dir="ltr" class="docs-sheet-tab-name" spellcheck="false">2</span></div></div></div></a></div></div></div></td></tr></tbody></table>
```

GASでのHTMLの操作は、XHTMLに準拠していれば [XML Service](https://developers.google.com/apps-script/reference/xml-service/)を使ってDOM的に操作することはできるのですが、シートのHTMLはパースできなかったのと、やりたいことは以下のように、

```diff
+ <div id="docs-editor">
+ <div id="waffle-grid-container">
<sheet>
+ </div>
+ <sheet-tab>
+ </div>
```

シートの前後にHTMLタグを追加するだけなので、一旦文字列操作で対処しました。

```ts
  // Insert sheets tab into sheets
  const newBlobs = blobs.map(blob => {
    if (blob.getName() === "resources/sheet.css") {
      return blob;
    }

    const html =
      '<div id="docs-editor">' +
      '<div id="waffle-grid-container" style="box-sizing: border-box; height: 100%; padding-bottom: 40px;">' +
      blob.getDataAsString() +
      "</div>" +
      // Create sheet tab HTML
      '<style>.docs-sheet-tab{height: 100%;padding: 8px 16px 0;}</style><table id="grid-bottom-bar" style="position: fixed; bottom: 0px; z-index: 1000;" class="grid-bottom-bar " cellspacing="0" cellpadding="0" dir="ltr" role="presentation"><tbody><tr><td style="width:120px;">&nbsp;</td><td class="docs-sheet-outer-container"><div class="goog-inline-block"><div class="docs-sheet-container goog-inline-block" style="height: 39px;"><div class="docs-sheet-container-bar goog-toolbar goog-inline-block" role="toolbar" style="user-select: none;">' +
      sheetNames.reduce((content, sheetName) => {
        const active = blob.getName().replace(".html", "") === sheetName ? " docs-sheet-active-tab" : "";
        return (
          content +
          '<a href="' +
          // Sheet HTML
          sheetName +
          '.html" class="goog-inline-block docs-sheet-tab docs-material' +
          active +
          '" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" id=":6qd"><div class="goog-inline-block docs-sheet-tab-outer-box"><div class="goog-inline-block docs-sheet-tab-inner-box"><div class="goog-inline-block docs-sheet-tab-caption"><span dir="ltr" class="docs-sheet-tab-name" spellcheck="false">' +
          sheetName +
          "</span></div></div></div></a>"
        );
      }, "") +
      "</div></div></div></td></tr></tbody></table>" +
      "</div>";

    blob.setDataFromString(html);

    return blob;
  });
```

結果、エンタープライザビリティ大幅UP！
![v2p1.png](https://qiita-image-store.s3.amazonaws.com/0/142668/bd190190-7f85-112d-c828-a8bf9c32773d.png)![v2p2.png](https://qiita-image-store.s3.amazonaws.com/0/142668/056d847f-92a0-b794-211e-b2c976c099b9.png)![v2p3.png](https://qiita-image-store.s3.amazonaws.com/0/142668/7c549ed6-9cc9-e070-1af7-70bc9f94519e.png)![v2p4.png](https://qiita-image-store.s3.amazonaws.com/0/142668/f97186a3-d6fc-70ae-46ca-0858e64835e1.png)


前回の Lighthouse スコアはパフォーマンスだけはいいって感じでしたが、
![v1score1.png](https://qiita-image-store.s3.amazonaws.com/0/142668/059cc275-24cf-9d94-1e0f-bd97f659e8d6.png)
今回はなぜかアクセシビリティとベストプラクティスが向上しました。
![v2score1.png](https://qiita-image-store.s3.amazonaws.com/0/142668/bb8466c2-5463-219e-7568-9cd9cc25634e.png)


## 成果物

是非ソースを見ていただきたい。IE5時代主流のテーブルコーディングを彷彿とさせるおびただしい数のTDタグ。
https://boiyaa-gas-sitegen-demo.firebaseapp.com

サイトジェネレーターのリポジトリはこちら。
https://github.com/boiyaa/gas-sitegen


以上、ご精読ありがとうございました。